From e925050207fbd6e503ebdf01d777b9c1ac6c8ffc Mon Sep 17 00:00:00 2001 From: Obltv Date: Tue, 16 Jun 2026 09:24:36 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E9=93=BE=E8=B7=AF=E8=BF=BD=E8=B8=AA=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E4=B8=8E=20RAG=20=E5=AF=B9=E8=AF=9D=E5=85=A8=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E5=9F=8B=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ruoyi-common-trace 模块:TraceContext 上下文、TraceNodeAspect 注解切面、数据库双表持久化 - ChatServiceFacade / KnowledgeRetrievalServiceImpl 完成 RAG 对话 retrieval → rerank → llm-call 全链路采集 - 新增 TraceController 提供列表/详情/节点查询 API 及数据统计 - 支持 trace.enabled 开关、payload 长度截断、错误安全降级 - TraceNodeTemplate 模板封装标准节点生命周期,减少重复样板代码 --- docs/script/sql/update/update-0615-trace.sql | 128 +++++++++ .../src/main/resources/application.yml | 18 ++ ruoyi-common/pom.xml | 1 + ruoyi-common/ruoyi-common-bom/pom.xml | 7 + ruoyi-common/ruoyi-common-trace/pom.xml | 42 +++ .../common/trace/annotation/TraceNode.java | 31 +++ .../common/trace/aspect/TraceNodeAspect.java | 89 ++++++ .../trace/config/TraceAutoConfiguration.java | 24 ++ .../common/trace/config/TraceProperties.java | 46 ++++ .../common/trace/constant/TraceConstants.java | 22 ++ .../trace/constant/TraceDisplayConstants.java | 163 +++++++++++ .../trace/core/DefaultTraceStreamSpan.java | 71 +++++ .../ruoyi/common/trace/core/TraceContext.java | 115 ++++++++ .../common/trace/core/TraceNodeTemplate.java | 159 +++++++++++ .../ruoyi/common/trace/core/TraceScope.java | 30 ++ .../common/trace/core/TraceStreamSpan.java | 15 + .../ruoyi/common/trace/domain/TraceNode.java | 45 +++ .../ruoyi/common/trace/domain/TraceRun.java | 40 +++ .../common/trace/domain/bo/TraceRunBo.java | 32 +++ .../common/trace/domain/vo/TraceDetailVo.java | 83 ++++++ .../common/trace/domain/vo/TraceNodeVo.java | 80 ++++++ .../common/trace/domain/vo/TraceRunVo.java | 54 ++++ .../common/trace/mapper/TraceNodeMapper.java | 11 + .../common/trace/mapper/TraceRunMapper.java | 11 + .../trace/service/TraceRecordService.java | 35 +++ .../service/impl/TraceRecordServiceImpl.java | 256 ++++++++++++++++++ .../common/trace/util/TracePayloadUtils.java | 100 +++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../trace/aspect/TraceNodeAspectTest.java | 134 +++++++++ .../core/DefaultTraceStreamSpanTest.java | 83 ++++++ .../common/trace/core/TraceContextTest.java | 57 ++++ .../trace/util/TracePayloadUtilsTest.java | 40 +++ ruoyi-modules/ruoyi-chat/pom.xml | 5 + .../service/chat/impl/ChatServiceFacade.java | 189 ++++++++++++- .../impl/KnowledgeRetrievalServiceImpl.java | 141 ++++++++-- .../org/ruoyi/trace/RagTraceNodeTypes.java | 18 ++ .../ruoyi/trace/RagTracePayloadBuilder.java | 132 +++++++++ .../trace/RagTracePayloadBuilderTest.java | 78 ++++++ ruoyi-modules/ruoyi-system/pom.xml | 5 + .../controller/monitor/TraceController.java | 68 +++++ 40 files changed, 2618 insertions(+), 41 deletions(-) create mode 100644 docs/script/sql/update/update-0615-trace.sql create mode 100644 ruoyi-common/ruoyi-common-trace/pom.xml create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/annotation/TraceNode.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/aspect/TraceNodeAspect.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceAutoConfiguration.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceProperties.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceConstants.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceDisplayConstants.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpan.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceContext.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceNodeTemplate.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceScope.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceStreamSpan.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceNode.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceRun.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/bo/TraceRunBo.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceDetailVo.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceNodeVo.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceRunVo.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceNodeMapper.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceRunMapper.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/TraceRecordService.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/impl/TraceRecordServiceImpl.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/util/TracePayloadUtils.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/aspect/TraceNodeAspectTest.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpanTest.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/core/TraceContextTest.java create mode 100644 ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/util/TracePayloadUtilsTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/trace/RagTraceNodeTypes.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/trace/RagTracePayloadBuilder.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/trace/RagTracePayloadBuilderTest.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/ruoyi/system/controller/monitor/TraceController.java diff --git a/docs/script/sql/update/update-0615-trace.sql b/docs/script/sql/update/update-0615-trace.sql new file mode 100644 index 00000000..974d9f63 --- /dev/null +++ b/docs/script/sql/update/update-0615-trace.sql @@ -0,0 +1,128 @@ +-- 链路追踪运行记录表 +CREATE TABLE IF NOT EXISTS `trace_run` ( + `id` bigint NOT NULL COMMENT '主键', + `trace_id` varchar(64) NOT NULL COMMENT '链路ID', + `trace_name` varchar(128) NOT NULL COMMENT '链路名称', + `business_type` varchar(64) NOT NULL COMMENT '业务类型', + `business_id` varchar(128) DEFAULT NULL COMMENT '业务ID', + `user_id` bigint DEFAULT NULL COMMENT '用户ID', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `status` varchar(32) NOT NULL COMMENT '状态', + `start_time` datetime NOT NULL COMMENT '开始时间', + `end_time` datetime DEFAULT NULL COMMENT '结束时间', + `duration_ms` bigint DEFAULT NULL COMMENT '耗时毫秒', + `error_message` varchar(1000) DEFAULT NULL COMMENT '错误摘要', + `metadata` text DEFAULT NULL COMMENT '元数据JSON', + `create_dept` bigint DEFAULT NULL COMMENT '创建部门', + `create_by` bigint DEFAULT NULL COMMENT '创建者', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` bigint DEFAULT NULL COMMENT '更新者', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_trace_run_trace_id` (`trace_id`) USING BTREE, + KEY `idx_trace_run_business` (`business_type`, `business_id`) USING BTREE, + KEY `idx_trace_run_status_time` (`status`, `start_time`) USING BTREE, + KEY `idx_trace_run_tenant_time` (`tenant_id`, `start_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='链路追踪运行记录表' ROW_FORMAT=DYNAMIC; + +-- 链路追踪节点记录表 +CREATE TABLE IF NOT EXISTS `trace_node` ( + `id` bigint NOT NULL COMMENT '主键', + `trace_id` varchar(64) NOT NULL COMMENT '链路ID', + `node_id` varchar(64) NOT NULL COMMENT '节点ID', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `parent_node_id` varchar(64) DEFAULT NULL COMMENT '父节点ID', + `node_name` varchar(128) NOT NULL COMMENT '节点名称', + `node_type` varchar(64) NOT NULL COMMENT '节点类型', + `depth` int DEFAULT 0 COMMENT '节点深度', + `sort_order` int DEFAULT 0 COMMENT '排序', + `class_name` varchar(255) DEFAULT NULL COMMENT '类名', + `method_name` varchar(128) DEFAULT NULL COMMENT '方法名', + `status` varchar(32) NOT NULL COMMENT '状态', + `start_time` datetime NOT NULL COMMENT '开始时间', + `end_time` datetime DEFAULT NULL COMMENT '结束时间', + `duration_ms` bigint DEFAULT NULL COMMENT '耗时毫秒', + `error_message` varchar(1000) DEFAULT NULL COMMENT '错误摘要', + `input_payload` text DEFAULT NULL COMMENT '输入JSON', + `output_payload` text DEFAULT NULL COMMENT '输出JSON', + `metadata` text DEFAULT NULL COMMENT '元数据JSON', + `create_dept` bigint DEFAULT NULL COMMENT '创建部门', + `create_by` bigint DEFAULT NULL COMMENT '创建者', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` bigint DEFAULT NULL COMMENT '更新者', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_trace_node_trace_id` (`trace_id`) USING BTREE, + KEY `idx_trace_node_parent` (`trace_id`, `parent_node_id`) USING BTREE, + KEY `idx_trace_node_time` (`trace_id`, `start_time`) USING BTREE, + KEY `idx_trace_node_tenant_time` (`tenant_id`, `start_time`) USING BTREE, + KEY `idx_trace_node_type_status` (`node_type`, `status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='链路追踪节点记录表' ROW_FORMAT=DYNAMIC; + +-- 兼容已存在的旧 trace 表:CREATE TABLE IF NOT EXISTS 不会给旧表补新字段 +SET @trace_run_add_tenant_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `trace_run` ADD COLUMN `tenant_id` varchar(20) DEFAULT ''000000'' COMMENT ''租户编号'' AFTER `user_id`', + 'SELECT 1' + ) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'trace_run' + AND COLUMN_NAME = 'tenant_id' +); +PREPARE trace_run_add_tenant_stmt FROM @trace_run_add_tenant_sql; +EXECUTE trace_run_add_tenant_stmt; +DEALLOCATE PREPARE trace_run_add_tenant_stmt; + +SET @trace_run_add_tenant_idx_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `trace_run` ADD INDEX `idx_trace_run_tenant_time` (`tenant_id`, `start_time`) USING BTREE', + 'SELECT 1' + ) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'trace_run' + AND INDEX_NAME = 'idx_trace_run_tenant_time' +); +PREPARE trace_run_add_tenant_idx_stmt FROM @trace_run_add_tenant_idx_sql; +EXECUTE trace_run_add_tenant_idx_stmt; +DEALLOCATE PREPARE trace_run_add_tenant_idx_stmt; + +SET @trace_node_add_tenant_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `trace_node` ADD COLUMN `tenant_id` varchar(20) DEFAULT ''000000'' COMMENT ''租户编号'' AFTER `node_id`', + 'SELECT 1' + ) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'trace_node' + AND COLUMN_NAME = 'tenant_id' +); +PREPARE trace_node_add_tenant_stmt FROM @trace_node_add_tenant_sql; +EXECUTE trace_node_add_tenant_stmt; +DEALLOCATE PREPARE trace_node_add_tenant_stmt; + +SET @trace_node_add_tenant_idx_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `trace_node` ADD INDEX `idx_trace_node_tenant_time` (`tenant_id`, `start_time`) USING BTREE', + 'SELECT 1' + ) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'trace_node' + AND INDEX_NAME = 'idx_trace_node_tenant_time' +); +PREPARE trace_node_add_tenant_idx_stmt FROM @trace_node_add_tenant_idx_sql; +EXECUTE trace_node_add_tenant_idx_stmt; +DEALLOCATE PREPARE trace_node_add_tenant_idx_stmt; + +-- 链路追踪监控菜单 & 按钮权限 +INSERT INTO `sys_menu` +(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`) +SELECT (SELECT COALESCE(MAX(`menu_id`), 0) + 1 FROM (SELECT `menu_id` FROM `sys_menu`) t), '链路追踪', 2, 7, 'trace', 'monitor/trace/index', '', 1, 0, 'C', '0', '0', 'monitor:trace:list', 'tabler:route', 103, 1, NOW(), '链路追踪监控菜单'; + +INSERT INTO `sys_menu` +(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`) +SELECT (SELECT COALESCE(MAX(`menu_id`), 0) + 1 FROM (SELECT `menu_id` FROM `sys_menu`) t), '链路追踪查询', (SELECT `menu_id` FROM `sys_menu` WHERE `perms` = 'monitor:trace:list' AND `menu_type` = 'C' LIMIT 1), 1, '#', '', '', 1, 0, 'F', '0', '0', 'monitor:trace:query', '#', 103, 1, NOW(), ''; diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 14d4faa4..a528f646 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -227,6 +227,24 @@ xss: excludeUrls: - /system/notice +--- # 链路追踪配置 +trace: + # 是否启用链路追踪,默认 true + # 关闭后所有埋点代码会直接透传业务逻辑,不写库、不创建上下文,零性能开销 + enabled: true + payload: + # 是否记录方法真实入参和返回值,默认 false(仅存摘要) + # 仅对 @TraceNode 注解方式生效,编程式埋点使用 TracePayloadBuilder 手动构建的摘要不受此开关影响 + # true → 自动序列化原始入参/返回值,截断后写入 input_payload / output_payload + # false → 只存 nodeName / 耗时 / 状态等元信息,input_payload / output_payload 为空 + record-detail: false + # input payload 最大字符长度,超过部分会被截断丢弃 + max-input-length: 1000 + # output payload 最大字符长度,超过部分会被截断丢弃 + max-output-length: 2000 + # 错误信息最大字符长度(格式: "异常类名: 异常消息"),超过部分会被截断丢弃 + max-error-length: 1000 + --- # 分布式锁 lock4j 全局配置 lock4j: # 获取分布式锁超时时间,默认为 3000 毫秒 diff --git a/ruoyi-common/pom.xml b/ruoyi-common/pom.xml index a91e84b0..e6b49eaa 100644 --- a/ruoyi-common/pom.xml +++ b/ruoyi-common/pom.xml @@ -35,6 +35,7 @@ ruoyi-common-tenant ruoyi-common-websocket ruoyi-common-sse + ruoyi-common-trace ruoyi-common diff --git a/ruoyi-common/ruoyi-common-bom/pom.xml b/ruoyi-common/ruoyi-common-bom/pom.xml index ec18b08a..9b0acbdd 100644 --- a/ruoyi-common/ruoyi-common-bom/pom.xml +++ b/ruoyi-common/ruoyi-common-bom/pom.xml @@ -186,6 +186,13 @@ ${revision} + + + org.ruoyi + ruoyi-common-trace + ${revision} + + diff --git a/ruoyi-common/ruoyi-common-trace/pom.xml b/ruoyi-common/ruoyi-common-trace/pom.xml new file mode 100644 index 00000000..011726ae --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/pom.xml @@ -0,0 +1,42 @@ + + + + org.ruoyi + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-trace + + + ruoyi-common-trace 通用链路追踪 + + + + + org.ruoyi + ruoyi-common-core + + + org.ruoyi + ruoyi-common-mybatis + + + org.ruoyi + ruoyi-common-json + + + org.springframework.boot + spring-boot-starter-test + test + + + org.mockito + mockito-junit-jupiter + test + + + diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/annotation/TraceNode.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/annotation/TraceNode.java new file mode 100644 index 00000000..7e8a8761 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/annotation/TraceNode.java @@ -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 ""; +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/aspect/TraceNodeAspect.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/aspect/TraceNodeAspect.java new file mode 100644 index 00000000..606afaf1 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/aspect/TraceNodeAspect.java @@ -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); + } + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceAutoConfiguration.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceAutoConfiguration.java new file mode 100644 index 00000000..d0475311 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceAutoConfiguration.java @@ -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); + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceProperties.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceProperties.java new file mode 100644 index 00000000..4e3c5247 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceProperties.java @@ -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; + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceConstants.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceConstants.java new file mode 100644 index 00000000..846d1589 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceConstants.java @@ -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"; +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceDisplayConstants.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceDisplayConstants.java new file mode 100644 index 00000000..48fc9f20 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceDisplayConstants.java @@ -0,0 +1,163 @@ +package org.ruoyi.common.trace.constant; + +import java.util.Map; + +/** + * 链路追踪中文展示常量。 + *

+ * 将技术标识映射为用户可读的中文标签,供前端直接展示。 + */ +public final class TraceDisplayConstants { + + private TraceDisplayConstants() { + } + + // ======================== 节点类型中文映射 ======================== + + /** + * 通用节点类型中文标签。 + */ + public static final Map 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 BUSINESS_TYPE_LABELS = Map.ofEntries( + Map.entry("RAG_CHAT", "RAG 对话"), + Map.entry("API", "API 调用"), + Map.entry("SCHEDULED", "定时任务") + ); + + /** + * 状态中文标签。 + */ + public static final Map 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); + } + + /** + * 将技术节点名称转为可读展示名。 + *

+ * 支持 kebab-case / snake_case / camelCase → 首字母大写空格分隔。 + */ + public static String prettifyNodeName(String raw) { + if (raw == null || raw.isBlank()) { + return "-"; + } + String trimmed = raw.trim(); + // 已知映射优先 + Map 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); + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpan.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpan.java new file mode 100644 index 00000000..a9bb9d64 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpan.java @@ -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); + } + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceContext.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceContext.java new file mode 100644 index 00000000..85db1dc0 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceContext.java @@ -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 TRACE_ID = new ThreadLocal<>(); + private static final ThreadLocal BUSINESS_TYPE = new ThreadLocal<>(); + private static final ThreadLocal BUSINESS_ID = new ThreadLocal<>(); + private static final ThreadLocal USER_ID = new ThreadLocal<>(); + private static final ThreadLocal TENANT_ID = new ThreadLocal<>(); + private static final ThreadLocal> 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 void setOrRemove(ThreadLocal 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 stack = NODE_STACK.get(); + return stack == null ? null : stack.peek(); + } + + public static int depth() { + Deque stack = NODE_STACK.get(); + return stack == null ? 0 : stack.size(); + } + + public static void pushNode(String nodeId) { + Deque stack = NODE_STACK.get(); + if (stack == null) { + stack = new ArrayDeque<>(); + NODE_STACK.set(stack); + } + stack.push(nodeId); + } + + public static void popNode() { + Deque stack = NODE_STACK.get(); + if (stack == null || stack.isEmpty()) { + return; + } + stack.pop(); + if (stack.isEmpty()) { + NODE_STACK.remove(); + } + } + + /** + * 为未来的跨线程上下文传播保留的拷贝入口,避免共享可变栈。 + */ + public static Deque copyNodeStack() { + Deque 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(); + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceNodeTemplate.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceNodeTemplate.java new file mode 100644 index 00000000..d60cb637 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceNodeTemplate.java @@ -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; + +/** + * 链路追踪节点模板,封装节点创建、上下文压栈、执行、结束、出栈的标准生命周期。 + *

+ * 用于同步方法内的 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 业务返回值类型 + * @return 业务执行结果 + */ + public static T withNode( + TraceRecordService traceRecordService, + TraceProperties traceProperties, + String nodeName, + String nodeType, + String className, + String methodName, + String inputPayload, + NodeAction action, + Function 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 withNode( + TraceRecordService traceRecordService, + TraceProperties traceProperties, + String nodeName, + String nodeType, + String className, + String methodName, + String inputPayload, + NodeAction 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 unwrap(NodeAction action) { + try { + return action.execute(); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException(e); + } + } + + @SuppressWarnings("unchecked") + private static T rethrow(Throwable t) throws T { + throw (T) t; + } + + /** + * 可抛出 Throwable 的业务动作。 + */ + @FunctionalInterface + public interface NodeAction { + T execute() throws Throwable; + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceScope.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceScope.java new file mode 100644 index 00000000..df05888c --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceScope.java @@ -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); + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceStreamSpan.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceStreamSpan.java new file mode 100644 index 00000000..349238d5 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceStreamSpan.java @@ -0,0 +1,15 @@ +package org.ruoyi.common.trace.core; + +/** + * 可跨回调结束的流式 trace 节点。 + */ +public interface TraceStreamSpan { + + void detach(); + + void finishSuccess(); + + void finishError(Throwable throwable); + + void finishCancelledIfRunning(); +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceNode.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceNode.java new file mode 100644 index 00000000..dece4bbb --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceNode.java @@ -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; +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceRun.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceRun.java new file mode 100644 index 00000000..ac7c05c4 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceRun.java @@ -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; +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/bo/TraceRunBo.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/bo/TraceRunBo.java new file mode 100644 index 00000000..bdcc81e2 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/bo/TraceRunBo.java @@ -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; +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceDetailVo.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceDetailVo.java new file mode 100644 index 00000000..43a07694 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceDetailVo.java @@ -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; + +/** + * 链路追踪详情视图对象。 + *

+ * 包含运行信息、节点树以及统计摘要。 + */ +@Data +public class TraceDetailVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private TraceRunVo run; + private List 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 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; + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceNodeVo.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceNodeVo.java new file mode 100644 index 00000000..1ac81555 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceNodeVo.java @@ -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; + +/** + * 链路追踪节点记录视图对象。 + *

+ * 除实体映射字段外,还提供前端可直接展示的显示标签和解析后的 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 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 parsedInput; + + /** output payload 解析为 Map,前端可直接读取结构化字段 */ + @JsonProperty("parsedOutput") + private Map parsedOutput; + + /** metadata 解析为 Map */ + @JsonProperty("parsedMetadata") + private Map parsedMetadata; +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceRunVo.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceRunVo.java new file mode 100644 index 00000000..db97e06e --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceRunVo.java @@ -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; + +/** + * 链路追踪运行记录视图对象。 + *

+ * 除实体映射字段外,还提供前端可直接展示的显示标签和解析后的 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 parsedMetadata; +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceNodeMapper.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceNodeMapper.java new file mode 100644 index 00000000..b5100e77 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceNodeMapper.java @@ -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 { +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceRunMapper.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceRunMapper.java new file mode 100644 index 00000000..ae88b5fb --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceRunMapper.java @@ -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 { +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/TraceRecordService.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/TraceRecordService.java new file mode 100644 index 00000000..69a7745d --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/TraceRecordService.java @@ -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 pageRuns(TraceRunBo bo, PageQuery pageQuery); + + TraceRunVo getRun(String traceId); + + List listNodes(String traceId); + + TraceDetailVo getDetail(String traceId); +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/impl/TraceRecordServiceImpl.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/impl/TraceRecordServiceImpl.java new file mode 100644 index 00000000..1b47173e --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/impl/TraceRecordServiceImpl.java @@ -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; + +/** + * 链路追踪记录服务实现。 + *

+ * 在自动映射的基础上,对 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 pageRuns(TraceRunBo bo, PageQuery pageQuery) { + LambdaQueryWrapper wrapper = buildRunWrapper(bo == null ? new TraceRunBo() : bo); + if (StringUtils.isBlank(pageQuery.getOrderByColumn())) { + wrapper.orderByDesc(TraceRun::getStartTime); + } + Page 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 listNodes(String traceId) { + List 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 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 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 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 buildRunWrapper(TraceRunBo bo) { + Map params = bo.getParams(); + return new LambdaQueryWrapper() + .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")); + } + +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/util/TracePayloadUtils.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/util/TracePayloadUtils.java new file mode 100644 index 00000000..84497bbe --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/util/TracePayloadUtils.java @@ -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 parseJsonToMap(String json) { + if (json == null || json.isBlank()) { + return Collections.emptyMap(); + } + try { + return OBJECT_MAPPER.readValue(json, new TypeReference>() {}); + } catch (JsonProcessingException e) { + // 非 JSON 字符串,返回包含原始文本的 Map + return Collections.singletonMap("_raw", json); + } + } + + private static String asString(Object value) { + return value instanceof String str ? str : toJson(value); + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-trace/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..14890863 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.ruoyi.common.trace.config.TraceAutoConfiguration diff --git a/ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/aspect/TraceNodeAspectTest.java b/ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/aspect/TraceNodeAspectTest.java new file mode 100644 index 00000000..15413b3f --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/aspect/TraceNodeAspectTest.java @@ -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 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 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"; + } + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpanTest.java b/ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpanTest.java new file mode 100644 index 00000000..f76b35ca --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpanTest.java @@ -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 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()); + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/core/TraceContextTest.java b/ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/core/TraceContextTest.java new file mode 100644 index 00000000..1347e6ec --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/core/TraceContextTest.java @@ -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 copy = TraceContext.copyNodeStack(); + copy.push("child"); + + assertEquals("root", TraceContext.currentNodeId()); + assertEquals("child", copy.peek()); + } +} diff --git a/ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/util/TracePayloadUtilsTest.java b/ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/util/TracePayloadUtilsTest.java new file mode 100644 index 00000000..7c787c43 --- /dev/null +++ b/ruoyi-common/ruoyi-common-trace/src/test/java/org/ruoyi/common/trace/util/TracePayloadUtilsTest.java @@ -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)); + } +} diff --git a/ruoyi-modules/ruoyi-chat/pom.xml b/ruoyi-modules/ruoyi-chat/pom.xml index f19e5a77..716e697e 100644 --- a/ruoyi-modules/ruoyi-chat/pom.xml +++ b/ruoyi-modules/ruoyi-chat/pom.xml @@ -29,6 +29,11 @@ ruoyi-common-sensitive + + org.ruoyi + ruoyi-common-trace + + dev.langchain4j langchain4j-open-ai diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index 16e0750d..028ff230 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -50,6 +50,16 @@ import org.ruoyi.common.core.utils.StringUtils; import org.ruoyi.common.satoken.utils.LoginHelper; import org.ruoyi.common.sse.core.SseEmitterManager; import org.ruoyi.common.sse.utils.SseMessageUtils; +import org.ruoyi.common.trace.config.TraceProperties; +import org.ruoyi.common.trace.constant.TraceConstants; +import org.ruoyi.common.trace.core.DefaultTraceStreamSpan; +import org.ruoyi.common.trace.core.TraceContext; +import org.ruoyi.common.trace.core.TraceScope; +import org.ruoyi.common.trace.core.TraceStreamSpan; +import org.ruoyi.common.trace.domain.TraceNode; +import org.ruoyi.common.trace.domain.TraceRun; +import org.ruoyi.common.trace.service.TraceRecordService; +import org.ruoyi.common.trace.util.TracePayloadUtils; import org.ruoyi.domain.bo.vector.QueryVectorBo; import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo; import org.ruoyi.factory.ChatServiceFactory; @@ -62,14 +72,19 @@ import org.ruoyi.service.knowledge.IKnowledgeInfoService; import org.ruoyi.service.retrieval.KnowledgeRetrievalService; import org.ruoyi.service.knowledge.retriever.CustomVectorRetriever; import org.ruoyi.service.vector.VectorStoreService; +import org.ruoyi.trace.RagTraceNodeTypes; +import org.ruoyi.trace.RagTracePayloadBuilder; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.ArrayList; +import java.util.Date; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; /** * 聊天服务门面层 @@ -106,6 +121,10 @@ public class ChatServiceFacade implements IChatService { private final ToolProviderFactory toolProviderFactory; + private final TraceRecordService traceRecordService; + + private final TraceProperties traceProperties; + /** * 内存实例缓存,避免同一会话重复创建 * Key: sessionId, Value: MessageWindowChatMemory实例 @@ -133,14 +152,10 @@ public class ChatServiceFacade implements IChatService { throw new IllegalArgumentException("模型不存在: " + chatRequest.getModel()); } - // 2. 构建上下文消息列表 - List contextMessages = buildContextMessages(chatRequest); - chatRequest.setEmitter(emitter); chatRequest.setUserId(userId); chatRequest.setTokenValue(tokenValue); chatRequest.setChatModelVo(chatModelVo); - chatRequest.setContextMessages(contextMessages); // 保存用户消息 chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), chatRequest.getContent(), RoleType.USER.getName(), chatRequest.getModel()); @@ -151,18 +166,145 @@ public class ChatServiceFacade implements IChatService { return sseEmitter; } - // 4. 路由服务提供商 - String providerCode = chatModelVo.getProviderCode(); + TraceRunHandle traceRun = startRagTraceRun(chatRequest, userId); + try (TraceScope ignored = openTraceScope(traceRun, userId)) { + // 4. 构建上下文消息列表,普通 RAG 检索会在当前 trace 上下文中执行 + List contextMessages = buildContextMessages(chatRequest); + chatRequest.setContextMessages(contextMessages); + + // 5. 路由并发起普通 RAG 对话 + executeNormalRagChat(chatRequest, userId, tokenValue, contextMessages, traceRun); + } catch (RuntimeException | Error e) { + finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, e); + throw e; + } + return emitter; + } + + private void executeNormalRagChat(ChatRequest chatRequest, Long userId, String tokenValue, + List contextMessages, TraceRunHandle traceRun) { + String providerCode = chatRequest.getChatModelVo().getProviderCode(); log.info("路由到服务提供商: {}, 模型: {}", providerCode, chatRequest.getModel()); AbstractChatService chatService = chatServiceFactory.getOriginalService(providerCode); - StreamingChatResponseHandler handler = createResponseHandler(userId, tokenValue,chatRequest); + TraceStreamSpan llmSpan = startLlmCallSpan(traceRun, chatRequest); + try { + StreamingChatResponseHandler handler = createResponseHandler(userId, tokenValue, chatRequest, + traceRun, llmSpan); + StreamingChatModel streamingChatModel = chatService.buildStreamingChatModel(chatRequest.getChatModelVo(), chatRequest); + streamingChatModel.chat(contextMessages, handler); + } catch (RuntimeException | Error e) { + if (llmSpan != null) { + llmSpan.finishError(e); + } + throw e; + } finally { + if (llmSpan != null) { + llmSpan.detach(); + } + } + } - // 5. 发起对话 - StreamingChatModel streamingChatModel = chatService.buildStreamingChatModel(chatModelVo, chatRequest); - streamingChatModel.chat(contextMessages, handler); - return emitter; + private TraceRunHandle startRagTraceRun(ChatRequest chatRequest, Long userId) { + if (!traceProperties.isEnabled()) { + return null; + } + + String traceId = UUID.randomUUID().toString().replace("-", ""); + long startMillis = System.currentTimeMillis(); + TraceRun run = new TraceRun(); + run.setTraceId(traceId); + run.setTraceName(RagTraceNodeTypes.TRACE_NAME_RAG_CHAT); + run.setBusinessType(RagTraceNodeTypes.BUSINESS_TYPE_RAG_CHAT); + run.setBusinessId(chatRequest.getSessionId() == null ? null : chatRequest.getSessionId().toString()); + run.setUserId(userId); + run.setTenantId(safeGetTenantId()); + run.setStatus(TraceConstants.STATUS_RUNNING); + run.setStartTime(new Date(startMillis)); + run.setMetadata(RagTracePayloadBuilder.chatRequestSummary(chatRequest)); + + try { + traceRecordService.startRun(run); + } catch (Exception e) { + log.warn("写入 RAG chat trace run 失败,traceId={}", traceId, e); + } + return new TraceRunHandle(traceId, startMillis, run.getBusinessId(), run.getTenantId()); + } + + private TraceScope openTraceScope(TraceRunHandle traceRun, Long userId) { + if (traceRun == null) { + return null; + } + return TraceContext.begin(traceRun.traceId, RagTraceNodeTypes.BUSINESS_TYPE_RAG_CHAT, + traceRun.businessId, userId, traceRun.tenantId); + } + + private TraceStreamSpan startLlmCallSpan(TraceRunHandle traceRun, ChatRequest chatRequest) { + if (traceRun == null || StringUtils.isBlank(TraceContext.getTraceId())) { + return null; + } + + String nodeId = UUID.randomUUID().toString().replace("-", ""); + long startMillis = System.currentTimeMillis(); + TraceNode node = new TraceNode(); + node.setTraceId(traceRun.traceId); + node.setNodeId(nodeId); + node.setParentNodeId(TraceContext.currentNodeId()); + node.setDepth(TraceContext.depth()); + node.setNodeName("llm-call"); + node.setNodeType(RagTraceNodeTypes.NODE_LLM_CALL); + node.setClassName(ChatServiceFacade.class.getName()); + node.setMethodName("executeNormalRagChat"); + node.setStatus(TraceConstants.STATUS_RUNNING); + node.setStartTime(new Date(startMillis)); + node.setInputPayload(RagTracePayloadBuilder.streamInputSummary(chatRequest)); + + try { + traceRecordService.startNode(node); + TraceContext.pushNode(nodeId); + return new DefaultTraceStreamSpan(traceRecordService, traceProperties, traceRun.traceId, nodeId, startMillis); + } catch (Exception e) { + log.warn("写入 LLM trace 节点失败,traceId={}, nodeId={}", traceRun.traceId, nodeId, e); + return null; + } + } + + private void finishTraceRun(TraceRunHandle traceRun, String status, Throwable error) { + if (traceRun == null || !traceRun.finished.compareAndSet(false, true)) { + return; + } + try { + traceRecordService.finishRun(traceRun.traceId, status, TracePayloadUtils.error(error, traceProperties), + new Date(), System.currentTimeMillis() - traceRun.startMillis); + } catch (Exception e) { + log.warn("结束 RAG chat trace run 失败,traceId={}", traceRun.traceId, e); + } + } + + private String safeGetTenantId() { + try { + return LoginHelper.getTenantId(); + } catch (Exception e) { + log.warn("获取 trace tenantId 失败: {}", e.getMessage()); + return null; + } + } + + private static final class TraceRunHandle { + + private final String traceId; + private final long startMillis; + private final String businessId; + private final String tenantId; + private final AtomicBoolean finished = new AtomicBoolean(false); + + private TraceRunHandle(String traceId, long startMillis, String businessId, String tenantId) { + this.traceId = traceId; + this.startMillis = startMillis; + this.businessId = businessId; + this.tenantId = tenantId; + } } /** @@ -509,6 +651,11 @@ public class ChatServiceFacade implements IChatService { * @return 标准的流式响应处理器 */ protected StreamingChatResponseHandler createResponseHandler(Long userId, String tokenValue,ChatRequest chatRequest) { + return createResponseHandler(userId, tokenValue, chatRequest, null, null); + } + + private StreamingChatResponseHandler createResponseHandler(Long userId, String tokenValue, ChatRequest chatRequest, + TraceRunHandle traceRun, TraceStreamSpan streamSpan) { return new StreamingChatResponseHandler() { private final StringBuilder messageBuffer = new StringBuilder(); @@ -540,19 +687,35 @@ public class ChatServiceFacade implements IChatService { chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), fullMessage, RoleType.ASSISTANT.getName(), chatRequest.getModel()); } + if (streamSpan != null) { + streamSpan.finishSuccess(); + } + finishTraceRun(traceRun, TraceConstants.STATUS_SUCCESS, null); + // 关闭SSE连接 SseMessageUtils.completeConnection(userId, tokenValue); log.info("消息结束,已保存到数据库"); } catch (Exception e) { log.error("完成响应时出错: {}", e.getMessage(), e); + if (streamSpan != null) { + streamSpan.finishError(e); + } + finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, e); } } @Override public void onError(Throwable error) { // 发送错误事件 - SseMessageUtils.sendError(userId, error.getMessage()); - log.error("流式响应错误: {}", error.getMessage()); + try { + SseMessageUtils.sendError(userId, error.getMessage()); + } finally { + if (streamSpan != null) { + streamSpan.finishError(error); + } + finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, error); + log.error("流式响应错误: {}", error.getMessage(), error); + } } }; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java index b6841ef1..56723057 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java @@ -3,6 +3,13 @@ package org.ruoyi.service.retrieval.impl; import lombok.RequiredArgsConstructor; 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.core.TraceContext; +import org.ruoyi.common.trace.core.TraceNodeTemplate; +import org.ruoyi.common.trace.domain.TraceNode; +import org.ruoyi.common.trace.service.TraceRecordService; +import org.ruoyi.common.trace.util.TracePayloadUtils; import org.ruoyi.domain.bo.rerank.RerankRequest; import org.ruoyi.domain.bo.rerank.RerankResult; import org.ruoyi.domain.bo.vector.QueryVectorBo; @@ -13,10 +20,13 @@ import org.ruoyi.mapper.knowledge.KnowledgeFragmentMapper; import org.ruoyi.service.rerank.RerankModelService; import org.ruoyi.service.retrieval.KnowledgeRetrievalService; import org.ruoyi.service.vector.VectorStoreService; +import org.ruoyi.trace.RagTraceNodeTypes; +import org.ruoyi.trace.RagTracePayloadBuilder; import org.springframework.stereotype.Service; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; /** @@ -34,6 +44,8 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService private final VectorStoreService vectorStoreService; private final RerankModelFactory rerankModelFactory; private final KnowledgeFragmentMapper fragmentMapper; + private final TraceRecordService traceRecordService; + private final TraceProperties traceProperties; /** * 粗召回默认扩大倍数 @@ -53,33 +65,40 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService public List retrieve(QueryVectorBo queryVectorBo) { log.info("开始知识库检索, kid={}, query={}", queryVectorBo.getKid(), queryVectorBo.getQuery()); - // 1. 粗召回阶段 (向量检索 + 关键词搜索) - List coarseResults = performCoarseRetrieval(queryVectorBo); - log.debug("粗召回返回 {} 条结果", coarseResults.size()); + return TraceNodeTemplate.withNode(traceRecordService, traceProperties, + "retrieval", RagTraceNodeTypes.NODE_RETRIEVAL, + KnowledgeRetrievalServiceImpl.class.getName(), "retrieve", + RagTracePayloadBuilder.retrievalInputSummary(queryVectorBo), + () -> { + // 1. 粗召回阶段 (向量检索 + 关键词搜索) + List coarseResults = performCoarseRetrieval(queryVectorBo); + log.debug("粗召回返回 {} 条结果", coarseResults.size()); - if (coarseResults.isEmpty()) { - return coarseResults; - } + if (coarseResults.isEmpty()) { + return coarseResults; + } - // 2. 初始化原始索引 - for (int i = 0; i < coarseResults.size(); i++) { - coarseResults.get(i).setOriginalIndex(i); - } + // 2. 初始化原始索引 + for (int i = 0; i < coarseResults.size(); i++) { + coarseResults.get(i).setOriginalIndex(i); + } - // 3. 重排序阶段 (可选) - List finalResults = coarseResults; - if (Boolean.TRUE.equals(queryVectorBo.getEnableRerank()) && - StringUtils.isNotBlank(queryVectorBo.getRerankModelName())) { - finalResults = performRerank(queryVectorBo, coarseResults); - } + // 3. 重排序阶段 (可选) + List finalResults = coarseResults; + if (Boolean.TRUE.equals(queryVectorBo.getEnableRerank()) && + StringUtils.isNotBlank(queryVectorBo.getRerankModelName())) { + finalResults = performRerank(queryVectorBo, coarseResults); + } - // 4. 应用分值阈值过滤 (重排分值或 RRF 分值) - double threshold = queryVectorBo.getRerankScoreThreshold() != null ? - queryVectorBo.getRerankScoreThreshold() : 0.0; - - return finalResults.stream() - .filter(res -> res.getScore() >= threshold) - .collect(Collectors.toList()); + // 4. 应用分值阈值过滤 (重排分值或 RRF 分值) + double threshold = queryVectorBo.getRerankScoreThreshold() != null ? + queryVectorBo.getRerankScoreThreshold() : 0.0; + + return finalResults.stream() + .filter(res -> res.getScore() >= threshold) + .collect(Collectors.toList()); + }, + RagTracePayloadBuilder::retrievalOutputSummary); } /** @@ -163,6 +182,9 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService * 重排序阶段 */ private List performRerank(QueryVectorBo queryVectorBo, List coarseResults) { + int topN = queryVectorBo.getRerankTopN() != null ? queryVectorBo.getRerankTopN() : queryVectorBo.getMaxResults(); + TraceNodeHandle traceNode = startTraceNode("rerank", RagTraceNodeTypes.NODE_RERANK, "performRerank", + RagTracePayloadBuilder.rerankInputSummary(queryVectorBo, coarseResults.size(), topN)); try { RerankModelService rerankModel = rerankModelFactory.createModel(queryVectorBo.getRerankModelName()); @@ -170,9 +192,6 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService .map(KnowledgeRetrievalVo::getContent) .collect(Collectors.toList()); - // topN 默认为 maxResults - int topN = queryVectorBo.getRerankTopN() != null ? queryVectorBo.getRerankTopN() : queryVectorBo.getMaxResults(); - RerankRequest rerankRequest = RerankRequest.builder() .query(queryVectorBo.getQuery()) .documents(contents) @@ -194,12 +213,18 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService coarseResults.sort((a, b) -> b.getScore().compareTo(a.getScore())); // 截断到 topN - return coarseResults.subList(0, Math.min(topN, coarseResults.size())); + List rerankedResults = coarseResults.subList(0, Math.min(topN, coarseResults.size())); + finishTraceNode(traceNode, TraceConstants.STATUS_SUCCESS, null, + RagTracePayloadBuilder.rerankOutputSummary(rerankedResults)); + return rerankedResults; } catch (Exception e) { log.error("重排序流程失败: {}", e.getMessage()); int limit = queryVectorBo.getMaxResults() != null ? queryVectorBo.getMaxResults() : 10; - return coarseResults.subList(0, Math.min(limit, coarseResults.size())); + List fallbackResults = coarseResults.subList(0, Math.min(limit, coarseResults.size())); + finishTraceNode(traceNode, TraceConstants.STATUS_ERROR, e, + RagTracePayloadBuilder.rerankOutputSummary(fallbackResults)); + return fallbackResults; } } @@ -242,6 +267,52 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService return fusedResults; } + private TraceNodeHandle startTraceNode(String nodeName, String nodeType, String methodName, String inputPayload) { + if (!traceProperties.isEnabled() || StringUtils.isBlank(TraceContext.getTraceId())) { + return null; + } + + String traceId = TraceContext.getTraceId(); + String nodeId = UUID.randomUUID().toString().replace("-", ""); + long startMillis = System.currentTimeMillis(); + 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(KnowledgeRetrievalServiceImpl.class.getName()); + node.setMethodName(methodName); + node.setStatus(TraceConstants.STATUS_RUNNING); + node.setStartTime(new Date(startMillis)); + node.setInputPayload(inputPayload); + + try { + traceRecordService.startNode(node); + TraceContext.pushNode(nodeId); + return new TraceNodeHandle(traceId, nodeId, startMillis); + } catch (Exception e) { + log.warn("写入 RAG 检索 trace 节点失败,traceId={}, nodeId={}", traceId, nodeId, e); + return null; + } + } + + private void finishTraceNode(TraceNodeHandle traceNode, String status, Throwable error, String outputPayload) { + if (traceNode == null || !traceNode.finished.compareAndSet(false, true)) { + return; + } + try { + traceRecordService.finishNode(traceNode.traceId, traceNode.nodeId, status, + TracePayloadUtils.error(error, traceProperties), outputPayload, + new Date(), System.currentTimeMillis() - traceNode.startMillis); + } catch (Exception e) { + log.warn("结束 RAG 检索 trace 节点失败,traceId={}, nodeId={}", traceNode.traceId, traceNode.nodeId, e); + } finally { + TraceContext.popNode(); + } + } + private QueryVectorBo copyOf(QueryVectorBo original, int maxResults) { QueryVectorBo copy = new QueryVectorBo(); copy.setQuery(original.getQuery()); @@ -253,4 +324,18 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService copy.setBaseUrl(original.getBaseUrl()); return copy; } + + private static final class TraceNodeHandle { + + private final String traceId; + private final String nodeId; + private final long startMillis; + private final AtomicBoolean finished = new AtomicBoolean(false); + + private TraceNodeHandle(String traceId, String nodeId, long startMillis) { + this.traceId = traceId; + this.nodeId = nodeId; + this.startMillis = startMillis; + } + } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/trace/RagTraceNodeTypes.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/trace/RagTraceNodeTypes.java new file mode 100644 index 00000000..ab751e03 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/trace/RagTraceNodeTypes.java @@ -0,0 +1,18 @@ +package org.ruoyi.trace; + +/** + * RAG trace 业务与节点类型常量。 + */ +public final class RagTraceNodeTypes { + + private RagTraceNodeTypes() { + } + + public static final String BUSINESS_TYPE_RAG_CHAT = "RAG_CHAT"; + public static final String TRACE_NAME_RAG_CHAT = "rag-chat"; + + public static final String NODE_RETRIEVAL = "RETRIEVAL"; + public static final String NODE_RERANK = "RERANK"; + public static final String NODE_LLM_CALL = "LLM_CALL"; + public static final String NODE_STREAM = "STREAM"; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/trace/RagTracePayloadBuilder.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/trace/RagTracePayloadBuilder.java new file mode 100644 index 00000000..b5a2255e --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/trace/RagTracePayloadBuilder.java @@ -0,0 +1,132 @@ +package org.ruoyi.trace; + +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.trace.util.TracePayloadUtils; +import org.ruoyi.domain.bo.vector.QueryVectorBo; +import org.ruoyi.domain.vo.knowledge.KnowledgeRetrievalVo; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * RAG trace payload 摘要构建器。 + */ +public final class RagTracePayloadBuilder { + + private static final int MAX_RESULT_SUMMARY_SIZE = 5; + + private RagTracePayloadBuilder() { + } + + public static String chatRequestSummary(ChatRequest request) { + Map payload = new LinkedHashMap<>(); + payload.put("requestPresent", request != null); + if (request == null) { + return TracePayloadUtils.toJson(payload); + } + + ChatModelVo model = request.getChatModelVo(); + payload.put("sessionId", request.getSessionId() == null ? null : request.getSessionId().toString()); + payload.put("model", request.getModel()); + payload.put("providerCode", model == null ? null : model.getProviderCode()); + payload.put("knowledgeId", request.getKnowledgeId()); + payload.put("hasKnowledge", request.getKnowledgeId() != null); + payload.put("contentLength", length(request.getContent())); + payload.put("contextMessageCount", request.getContextMessages() == null ? null : request.getContextMessages().size()); + payload.put("enableWorkFlow", request.getEnableWorkFlow()); + payload.put("isResume", request.getIsResume()); + payload.put("enableThinking", request.getEnableThinking()); + return TracePayloadUtils.toJson(payload); + } + + public static String retrievalInputSummary(QueryVectorBo query) { + Map payload = new LinkedHashMap<>(); + payload.put("queryPresent", query != null); + if (query == null) { + return TracePayloadUtils.toJson(payload); + } + + payload.put("kid", query.getKid()); + payload.put("queryLength", length(query.getQuery())); + payload.put("maxResults", query.getMaxResults()); + payload.put("vectorModelName", query.getVectorModelName()); + payload.put("embeddingModelName", query.getEmbeddingModelName()); + payload.put("enableHybrid", query.getEnableHybrid()); + payload.put("hybridAlpha", query.getHybridAlpha()); + payload.put("similarityThreshold", query.getSimilarityThreshold()); + payload.put("enableRerank", query.getEnableRerank()); + payload.put("rerankModel", query.getRerankModelName()); + payload.put("rerankTopN", query.getRerankTopN()); + payload.put("rerankScoreThreshold", query.getRerankScoreThreshold()); + return TracePayloadUtils.toJson(payload); + } + + public static String retrievalOutputSummary(List results) { + Map payload = new LinkedHashMap<>(); + payload.put("resultCount", results == null ? 0 : results.size()); + payload.put("results", summarizeResults(results)); + return TracePayloadUtils.toJson(payload); + } + + public static String rerankInputSummary(QueryVectorBo query, int candidateCount, Integer topN) { + Map payload = new LinkedHashMap<>(); + payload.put("candidateCount", candidateCount); + payload.put("rerankModel", query == null ? null : query.getRerankModelName()); + payload.put("topN", topN); + return TracePayloadUtils.toJson(payload); + } + + public static String rerankOutputSummary(List results) { + Map payload = new LinkedHashMap<>(); + payload.put("resultCount", results == null ? 0 : results.size()); + payload.put("results", summarizeResults(results)); + return TracePayloadUtils.toJson(payload); + } + + public static String streamInputSummary(ChatRequest request) { + Map payload = new LinkedHashMap<>(); + payload.put("sessionId", request == null || request.getSessionId() == null ? null : request.getSessionId().toString()); + payload.put("model", request == null ? null : request.getModel()); + payload.put("contextMessageCount", request == null || request.getContextMessages() == null ? null : request.getContextMessages().size()); + payload.put("contentLength", request == null ? null : length(request.getContent())); + return TracePayloadUtils.toJson(payload); + } + + public static String streamOutputSummary(int responseLength) { + Map payload = new LinkedHashMap<>(); + payload.put("responseLength", responseLength); + return TracePayloadUtils.toJson(payload); + } + + private static List> summarizeResults(List results) { + List> summaries = new ArrayList<>(); + if (results == null || results.isEmpty()) { + return summaries; + } + + int limit = Math.min(MAX_RESULT_SUMMARY_SIZE, results.size()); + for (int i = 0; i < limit; i++) { + KnowledgeRetrievalVo result = results.get(i); + Map item = new LinkedHashMap<>(); + item.put("rank", i + 1); + item.put("id", result == null ? null : result.getId()); + item.put("docId", result == null ? null : result.getDocId()); + item.put("knowledgeId", result == null || result.getKnowledgeId() == null ? null : result.getKnowledgeId().toString()); + item.put("idx", result == null ? null : result.getIdx()); + item.put("score", result == null ? null : result.getScore()); + item.put("rawScore", result == null ? null : result.getRawScore()); + item.put("originalIndex", result == null ? null : result.getOriginalIndex()); + item.put("sourceName", result == null ? null : result.getSourceName()); + item.put("contentLength", result == null ? null : length(result.getContent())); + summaries.add(item); + } + return summaries; + } + + private static Integer length(String value) { + return value == null ? null : value.length(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/trace/RagTracePayloadBuilderTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/trace/RagTracePayloadBuilderTest.java new file mode 100644 index 00000000..b6a242f5 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/trace/RagTracePayloadBuilderTest.java @@ -0,0 +1,78 @@ +package org.ruoyi.trace; + +import org.junit.jupiter.api.Test; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.domain.bo.vector.QueryVectorBo; +import org.ruoyi.domain.vo.knowledge.KnowledgeRetrievalVo; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RagTracePayloadBuilderTest { + + @Test + void chatRequestSummaryShouldHandleNullsAndAvoidPromptBody() { + ChatRequest request = new ChatRequest(); + request.setSessionId(100L); + request.setModel("qwen-plus"); + request.setKnowledgeId("200"); + request.setContent("secret prompt body"); + + ChatModelVo model = new ChatModelVo(); + model.setProviderCode("dashscope"); + request.setChatModelVo(model); + + String payload = RagTracePayloadBuilder.chatRequestSummary(request); + + assertTrue(payload.contains("\"sessionId\":100")); + assertTrue(payload.contains("\"contentLength\":18")); + assertTrue(payload.contains("\"providerCode\":\"dashscope\"")); + assertFalse(payload.contains("secret prompt body")); + } + + @Test + void retrievalInputSummaryShouldUseSummaryOnly() { + QueryVectorBo query = new QueryVectorBo(); + query.setKid("200"); + query.setQuery("private retrieval query"); + query.setMaxResults(5); + query.setEnableRerank(true); + query.setRerankModelName("gte-rerank"); + query.setRerankTopN(null); + + String payload = RagTracePayloadBuilder.retrievalInputSummary(query); + + assertTrue(payload.contains("\"kid\":\"200\"")); + assertTrue(payload.contains("\"queryLength\":23")); + assertTrue(payload.contains("\"enableRerank\":true")); + assertFalse(payload.contains("private retrieval query")); + } + + @Test + void retrievalOutputSummaryShouldAvoidFragmentContent() { + KnowledgeRetrievalVo result = new KnowledgeRetrievalVo(); + result.setId("fragment-1"); + result.setDocId("doc-1"); + result.setIdx(1); + result.setScore(0.85); + result.setContent("sensitive knowledge fragment"); + + String payload = RagTracePayloadBuilder.retrievalOutputSummary(List.of(result)); + + assertTrue(payload.contains("\"resultCount\":1")); + assertTrue(payload.contains("\"contentLength\":28")); + assertTrue(payload.contains("\"fragment-1\"")); + assertFalse(payload.contains("sensitive knowledge fragment")); + } + + @Test + void summariesShouldAcceptNullValuesWithoutMapOfNpe() { + assertTrue(RagTracePayloadBuilder.chatRequestSummary(null).contains("\"requestPresent\":false")); + assertTrue(RagTracePayloadBuilder.retrievalInputSummary(null).contains("\"queryPresent\":false")); + assertTrue(RagTracePayloadBuilder.retrievalOutputSummary(null).contains("\"resultCount\":0")); + assertTrue(RagTracePayloadBuilder.rerankInputSummary(null, 0, null).contains("\"candidateCount\":0")); + } +} diff --git a/ruoyi-modules/ruoyi-system/pom.xml b/ruoyi-modules/ruoyi-system/pom.xml index 4a8a22ad..02a03e01 100644 --- a/ruoyi-modules/ruoyi-system/pom.xml +++ b/ruoyi-modules/ruoyi-system/pom.xml @@ -48,6 +48,11 @@ ruoyi-common-log + + org.ruoyi + ruoyi-common-trace + + org.ruoyi diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/ruoyi/system/controller/monitor/TraceController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/ruoyi/system/controller/monitor/TraceController.java new file mode 100644 index 00000000..bf44ba87 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/ruoyi/system/controller/monitor/TraceController.java @@ -0,0 +1,68 @@ +package org.ruoyi.system.controller.monitor; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.common.mybatis.core.page.PageQuery; +import org.ruoyi.common.mybatis.core.page.TableDataInfo; +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.service.TraceRecordService; +import org.ruoyi.common.web.core.BaseController; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 链路追踪监控 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/monitor/trace") +public class TraceController extends BaseController { + + private final TraceRecordService traceRecordService; + + /** + * 获取链路追踪运行列表 + */ + @SaCheckPermission("monitor:trace:list") + @GetMapping("/run/list") + public TableDataInfo list(TraceRunBo bo, PageQuery pageQuery) { + return traceRecordService.pageRuns(bo, pageQuery); + } + + /** + * 获取链路追踪运行详情 + */ + @SaCheckPermission("monitor:trace:query") + @GetMapping("/run/{traceId}") + public R run(@PathVariable String traceId) { + return R.ok(traceRecordService.getRun(traceId)); + } + + /** + * 获取链路追踪节点列表 + */ + @SaCheckPermission("monitor:trace:query") + @GetMapping("/node/list/{traceId}") + public R> nodes(@PathVariable String traceId) { + return R.ok(traceRecordService.listNodes(traceId)); + } + + /** + * 获取链路追踪完整详情 + */ + @SaCheckPermission("monitor:trace:query") + @GetMapping("/detail/{traceId}") + public R detail(@PathVariable String traceId) { + return R.ok(traceRecordService.getDetail(traceId)); + } +} From 36e3cf8c52918d53bc8ea6f9b078eec1ad14ac77 Mon Sep 17 00:00:00 2001 From: Obltv Date: Thu, 23 Jul 2026 14:45:43 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E8=BF=BD=E8=B8=AA=E8=BE=93=E5=87=BA=E8=AE=B0=E5=BD=95=E5=8F=8A?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E8=BF=BD=E8=B8=AA=E6=97=B6=E7=9A=84=E9=A2=9D?= =?UTF-8?q?=E5=A4=96=E5=BC=80=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/trace/core/DefaultTraceStreamSpan.java | 12 ++++++------ .../org/ruoyi/common/trace/core/TraceStreamSpan.java | 9 ++++++++- .../ruoyi/service/chat/impl/ChatServiceFacade.java | 3 ++- .../impl/KnowledgeRetrievalServiceImpl.java | 10 +++++++++- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpan.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpan.java index a9bb9d64..a73d18a0 100644 --- a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpan.java +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpan.java @@ -43,26 +43,26 @@ public class DefaultTraceStreamSpan implements TraceStreamSpan { } @Override - public void finishSuccess() { - finish(TraceConstants.STATUS_SUCCESS, null); + public void finishSuccess(String outputPayload) { + finish(TraceConstants.STATUS_SUCCESS, null, outputPayload); } @Override public void finishError(Throwable throwable) { - finish(TraceConstants.STATUS_ERROR, TracePayloadUtils.error(throwable, traceProperties)); + finish(TraceConstants.STATUS_ERROR, TracePayloadUtils.error(throwable, traceProperties), null); } @Override public void finishCancelledIfRunning() { - finish(TraceConstants.STATUS_CANCELLED, null); + finish(TraceConstants.STATUS_CANCELLED, null, null); } - private void finish(String status, String errorMessage) { + private void finish(String status, String errorMessage, String outputPayload) { if (!finished.compareAndSet(false, true)) { return; } try { - traceRecordService.finishNode(traceId, nodeId, status, errorMessage, null, + traceRecordService.finishNode(traceId, nodeId, status, errorMessage, outputPayload, new Date(), System.currentTimeMillis() - startMillis); } catch (Exception e) { log.warn("结束 trace stream span 失败,traceId={}, nodeId={}", traceId, nodeId, e); diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceStreamSpan.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceStreamSpan.java index 349238d5..0a82eabc 100644 --- a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceStreamSpan.java +++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceStreamSpan.java @@ -7,7 +7,14 @@ public interface TraceStreamSpan { void detach(); - void finishSuccess(); + /** + * 结束成功的流式节点,并可写入输出摘要。 + */ + default void finishSuccess() { + finishSuccess(null); + } + + void finishSuccess(String outputPayload); void finishError(Throwable throwable); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index 678006f1..2f04c806 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -337,7 +337,8 @@ public class ChatServiceFacade implements IChatService { result, RoleType.ASSISTANT.getName(), chatRequest.getModel()); } if (llmSpan != null) { - llmSpan.finishSuccess(); + llmSpan.finishSuccess(RagTracePayloadBuilder.streamOutputSummary( + result == null ? 0 : result.length())); } finishTraceRun(traceRun, TraceConstants.STATUS_SUCCESS, null); } catch (Exception e) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java index 938fdced..5d3cb5e6 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java @@ -76,6 +76,8 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService } log.info("开始知识库检索, kid={}, query={}", queryVectorBo.getKid(), queryVectorBo.getQuery()); + String retrievalInputPayload = traceActive() + ? RagTracePayloadBuilder.retrievalInputSummary(queryVectorBo) : null; List finalResults = TraceNodeTemplate.withNode(traceRecordService, traceProperties, "retrieval", RagTraceNodeTypes.NODE_RETRIEVAL, KnowledgeRetrievalServiceImpl.class.getName(), "retrieve", @@ -206,8 +208,10 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService */ private List performRerank(QueryVectorBo queryVectorBo, List coarseResults) { int topN = queryVectorBo.getRerankTopN() != null ? queryVectorBo.getRerankTopN() : queryVectorBo.getMaxResults(); + String rerankInputPayload = traceActive() + ? RagTracePayloadBuilder.rerankInputSummary(queryVectorBo, coarseResults.size(), topN) : null; TraceNodeHandle traceNode = startTraceNode("rerank", RagTraceNodeTypes.NODE_RERANK, "performRerank", - RagTracePayloadBuilder.rerankInputSummary(queryVectorBo, coarseResults.size(), topN)); + rerankInputPayload); try { RerankModelService rerankModel = rerankModelFactory.createModel(queryVectorBo.getRerankModelName()); @@ -324,6 +328,10 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService } } + private boolean traceActive() { + return traceProperties.isEnabled() && StringUtils.isNotBlank(TraceContext.getTraceId()); + } + private void finishTraceNode(TraceNodeHandle traceNode, String status, Throwable error, String outputPayload) { if (traceNode == null || !traceNode.finished.compareAndSet(false, true)) { return; From 345d2819d89b24adde61154d7daf6384f160574d Mon Sep 17 00:00:00 2001 From: Obltv Date: Thu, 23 Jul 2026 14:52:19 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E8=BF=BD=E8=B8=AA=E8=BE=93=E5=87=BA=E8=AE=B0=E5=BD=95=E5=8F=8A?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E8=BF=BD=E8=B8=AA=E6=97=B6=E7=9A=84=E9=A2=9D?= =?UTF-8?q?=E5=A4=96=E5=BC=80=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/retrieval/impl/KnowledgeRetrievalServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java index 5d3cb5e6..89295a69 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java @@ -81,7 +81,7 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService List finalResults = TraceNodeTemplate.withNode(traceRecordService, traceProperties, "retrieval", RagTraceNodeTypes.NODE_RETRIEVAL, KnowledgeRetrievalServiceImpl.class.getName(), "retrieve", - RagTracePayloadBuilder.retrievalInputSummary(queryVectorBo), + retrievalInputPayload, () -> retrieveUncached(queryVectorBo), RagTracePayloadBuilder::retrievalOutputSummary); cache(cacheKey, finalResults);