feat(trace): 实现语义化 Trace ID 机制并美化报告展现

- [TraceContext] 重构 ID 生成算法,支持包含群名和时间(如 manual_技术交流群_1733),极大提升日志调试效率。
 - [视觉美化] 从用户可见消息(初始提示及图片 Caption)中彻底移除技术性的 [ID: xxx] 字符串。
 - [去重增强] 在报告 Caption 中引入基于秒级时间戳的“隐式指纹”,配合共享正则匹配,确保精准去重。
 - [自动分析] 优化自动调度器逻辑,引入群名缓存,使服务端定时任务日志更具业务可读性。
 - [逻辑精简] 利用插件现有的任务并发锁,移除了 Trace ID 中冗余的 4 位随机后缀,进一步精简长度。
This commit is contained in:
SXP-Simon
2026-03-12 18:03:23 +08:00
parent 16c756259a
commit bd991e9c16
5 changed files with 113 additions and 31 deletions
+19 -7
View File
@@ -466,12 +466,23 @@ class GroupDailyAnalysis(Star):
yield event.plain_result("❌ 此群未启用日常分析功能") yield event.plain_result("❌ 此群未启用日常分析功能")
return return
# 设置 TraceID # 获取群名以生成语义化的 TraceID
trace_id = TraceContext.generate(prefix=f"manual_{group_id}") group_name = ""
try:
adapter = self.bot_manager.get_adapter(platform_id)
if adapter:
info = await adapter.get_group_info(group_id)
if info and info.group_name:
group_name = info.group_name
except Exception:
pass
# 设置 TraceID (语义化格式: manual_群名_HHmm)
trace_id = TraceContext.generate(prefix="manual", group_name=group_name or group_id)
TraceContext.set(trace_id) TraceContext.set(trace_id)
yield event.plain_result( yield event.plain_result(
f"🔍 正在启动跨平台分析引擎,正在拉取最近消息...\n[ID: {trace_id}]" "🔍 正在启动跨平台分析引擎,正在拉取最近消息..."
) )
try: try:
@@ -492,7 +503,7 @@ class GroupDailyAnalysis(Star):
f"📊 已获取{result['messages_count']}条消息,正在生成渲染报告..." f"📊 已获取{result['messages_count']}条消息,正在生成渲染报告..."
) )
async for res in self._send_analysis_report(event, result, trace_id): async for res in self._send_analysis_report(event, result):
yield res yield res
except DuplicateGroupTaskError: except DuplicateGroupTaskError:
@@ -504,7 +515,7 @@ class GroupDailyAnalysis(Star):
) )
async def _send_analysis_report( async def _send_analysis_report(
self, event: AstrMessageEvent, result: dict, trace_id: str self, event: AstrMessageEvent, result: dict
) -> AsyncGenerator: ) -> AsyncGenerator:
"""处理分析结果的渲染和发送""" """处理分析结果的渲染和发送"""
group_id = result["group_id"] group_id = result["group_id"]
@@ -536,17 +547,18 @@ class GroupDailyAnalysis(Star):
) )
if image_url: if image_url:
caption = f"📊 每日群聊分析报告已生成:\n[ID: {trace_id}]" caption = TraceContext.make_report_caption()
await adapter.send_image(group_id, image_url, caption=caption) await adapter.send_image(group_id, image_url, caption=caption)
await self._try_upload_image(group_id, image_url, platform_id) await self._try_upload_image(group_id, image_url, platform_id)
elif html_content: elif html_content:
yield event.plain_result("⚠️ 群分析报告图片发送失败,自动重试中。") yield event.plain_result("⚠️ 群分析报告图片发送失败,自动重试中。")
caption = TraceContext.make_report_caption()
await self.retry_manager.add_task( await self.retry_manager.add_task(
html_content, html_content,
analysis_result, analysis_result,
group_id, group_id,
platform_id, platform_id,
caption=f"📊 每日群聊分析报告已生成:\n[ID: {trace_id}]", caption=caption,
) )
else: else:
text_report = self.report_generator.generate_text_report( text_report = self.report_generator.generate_text_report(
@@ -22,6 +22,7 @@ from ....domain.value_objects.unified_message import (
MessageContentType, MessageContentType,
UnifiedMessage, UnifiedMessage,
) )
from ....shared.trace_context import REPORT_CAPTION_PATTERN
from ....utils.logger import logger from ....utils.logger import logger
from ..base import PlatformAdapter from ..base import PlatformAdapter
@@ -644,14 +645,12 @@ class OneBotAdapter(PlatformAdapter):
"[OneBot] was_image_sent_recently: 无法确定机器人 ID,历史回显校验可能不准确" "[OneBot] was_image_sent_recently: 无法确定机器人 ID,历史回显校验可能不准确"
) )
# [优化] 如果提供了 token,我们也尝试caption 中提取 ID 部分进行更精准匹配 # [优化] 从 Caption 中提取基于时间戳的去重 Token
search_token = None search_token = None
if token and "[ID: " in token: if token:
import re match = REPORT_CAPTION_PATTERN.search(token)
match = re.search(r"\[ID: ([^\]]+)\]", token)
if match: if match:
search_token = match.group(0) # 例如 "[ID: report_XXXX]" search_token = match.group(0) # 例如 "| 03-12 17:33:20"
for msg in reversed(messages): for msg in reversed(messages):
msg_time = msg.get("time", 0) msg_time = msg.get("time", 0)
+6 -6
View File
@@ -38,7 +38,7 @@ class ReportDispatcher:
trace_id = TraceContext.get() trace_id = TraceContext.get()
output_format = self.config_manager.get_output_format() output_format = self.config_manager.get_output_format()
logger.info( logger.info(
f"[{trace_id}] Dispatching report for group {group_id} (Format: {output_format})" f"[{trace_id}] 正在分发群 {group_id} 的报告 (格式: {output_format})"
) )
success = False success = False
@@ -51,11 +51,11 @@ class ReportDispatcher:
if success: if success:
logger.info( logger.info(
f"[{trace_id}] Report dispatched successfully for group {group_id}" f"[{trace_id}] {group_id} 的报告分发成功"
) )
else: else:
logger.warning( logger.warning(
f"[{trace_id}] Failed to dispatch report for group {group_id}" f"[{trace_id}] {group_id} 的报告分发失败"
) )
async def _dispatch_image( async def _dispatch_image(
@@ -65,7 +65,7 @@ class ReportDispatcher:
# 1. 检查渲染函数 # 1. 检查渲染函数
if not self._html_render_func: if not self._html_render_func:
logger.warning( logger.warning(
f"[{trace_id}] HTML render function not set, falling back to text." f"[{trace_id}] 未设置 HTML 渲染函数,回退到文本模式。"
) )
return await self._dispatch_text(group_id, analysis_result, platform_id) return await self._dispatch_text(group_id, analysis_result, platform_id)
@@ -94,7 +94,7 @@ class ReportDispatcher:
# 3. 发送图片 # 3. 发送图片
if image_url: if image_url:
caption = f"📊 每日群聊分析报告已生成:\n[ID: {trace_id}]" caption = TraceContext.make_report_caption()
sent = await self.message_sender.send_image_smart( sent = await self.message_sender.send_image_smart(
group_id, image_url, caption, platform_id group_id, image_url, caption, platform_id
) )
@@ -120,7 +120,7 @@ class ReportDispatcher:
analysis_result, analysis_result,
group_id, group_id,
platform_id, platform_id,
caption=f"📊 每日群聊分析报告已生成:\n[ID: {trace_id}]", caption=TraceContext.make_report_caption(),
) )
return True # 已加入队列视作处理成功 (不在此处报错) return True # 已加入队列视作处理成功 (不在此处报错)
else: else:
+36 -6
View File
@@ -49,6 +49,9 @@ class AutoScheduler:
self.scheduler_job_ids = [] # 存储已注册的定时任务 ID self.scheduler_job_ids = [] # 存储已注册的定时任务 ID
self.last_executed_target = None # 记录上次执行的具体时间点,防止重复执行 self.last_executed_target = None # 记录上次执行的具体时间点,防止重复执行
# Cache: group_id -> group_name (populated lazily)
self._group_name_cache: dict[str, str] = {}
def set_bot_instance(self, bot_instance): def set_bot_instance(self, bot_instance):
"""设置bot实例(保持向后兼容)""" """设置bot实例(保持向后兼容)"""
self.bot_manager.set_bot_instance(bot_instance) self.bot_manager.set_bot_instance(bot_instance)
@@ -110,6 +113,30 @@ class AutoScheduler:
except Exception as e: except Exception as e:
logger.error(f"❌ 获取平台ID失败: {e}") logger.error(f"❌ 获取平台ID失败: {e}")
return None return None
async def _get_group_name_safe(
self, group_id: str, platform_id: str | None = None
) -> str:
"""
为 TraceID 生成解析可读的群名。
使用内存缓存以避免重复的 API 调用。
若名称不可用,则回退到 group_id。
"""
if group_id in self._group_name_cache:
return self._group_name_cache[group_id]
try:
pid = platform_id or await self.get_platform_id_for_group(group_id)
if pid:
adapter = self.bot_manager.get_adapter(pid)
if adapter:
info = await adapter.get_group_info(group_id)
if info and info.group_name:
self._group_name_cache[group_id] = info.group_name
return info.group_name
except Exception:
pass
return group_id
# ================================================================ # ================================================================
# 任务注册与取消 # 任务注册与取消
@@ -381,8 +408,9 @@ class AutoScheduler:
): ):
"""为指定群执行自动分析(业务逻辑委派给 AnalysisApplicationService""" """为指定群执行自动分析(业务逻辑委派给 AnalysisApplicationService"""
try: try:
# 设置 TraceID # 解析可读群名以生成语义化的 TraceID
trace_id = TraceContext.generate(prefix=f"group_{group_id}") group_name = await self._get_group_name_safe(group_id, target_platform_id)
trace_id = TraceContext.generate(prefix="group", group_name=group_name)
TraceContext.set(trace_id) TraceContext.set(trace_id)
logger.info( logger.info(
@@ -542,8 +570,9 @@ class AutoScheduler:
): ):
"""为指定群执行增量分析(业务逻辑委派给 AnalysisApplicationService""" """为指定群执行增量分析(业务逻辑委派给 AnalysisApplicationService"""
try: try:
# 设置 TraceID # 解析可读群名以生成语义化的 TraceID
trace_id = TraceContext.generate(prefix=f"incr_{group_id}") group_name = await self._get_group_name_safe(group_id, target_platform_id)
trace_id = TraceContext.generate(prefix="incr", group_name=group_name)
TraceContext.set(trace_id) TraceContext.set(trace_id)
logger.info( logger.info(
@@ -683,8 +712,9 @@ class AutoScheduler:
): ):
"""为指定群生成增量最终报告(业务逻辑委派给 AnalysisApplicationService""" """为指定群生成增量最终报告(业务逻辑委派给 AnalysisApplicationService"""
try: try:
# 设置 TraceID # 解析可读群名以生成语义化的 TraceID
trace_id = TraceContext.generate(prefix=f"report_{group_id}") group_name = await self._get_group_name_safe(group_id, target_platform_id)
trace_id = TraceContext.generate(prefix="report", group_name=group_name)
TraceContext.set(trace_id) TraceContext.set(trace_id)
logger.info( logger.info(
+47 -6
View File
@@ -6,12 +6,20 @@
import functools import functools
import logging import logging
import re
import uuid import uuid
from contextvars import ContextVar, Token from contextvars import ContextVar, Token
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Any, Optional from typing import Any, Optional
# Trace ID 中群名的最大长度(平衡可读性和日志宽度)
_MAX_GROUP_NAME_LEN = 10
# 用于匹配报告 Caption 中去重 Token 的正则模式
# 格式: "| MM-DD HH:MM:SS"
REPORT_CAPTION_PATTERN = re.compile(r"\| (\d{2}-\d{2} \d{2}:\d{2}:\d{2})")
# 当前追踪的上下文变量 # 当前追踪的上下文变量
_current_trace: ContextVar[Optional["TraceContext"]] = ContextVar( _current_trace: ContextVar[Optional["TraceContext"]] = ContextVar(
"current_trace", default=None "current_trace", default=None
@@ -146,18 +154,51 @@ class TraceContext:
return new_ctx return new_ctx
@staticmethod @staticmethod
def generate(prefix: str = "") -> str: def generate(prefix: str = "", group_name: str = "") -> str:
""" """
[兼容性接口] 生成一个带前缀的唯一追踪 ID。 生成语义化、易读的追踪 ID。
格式: {来源}_{群名}_{时间点}
示例: manual_系统交流群_1733
由于插件存在任务锁 (DuplicateGroupTaskError),确保了一个群同一时间只有一个分析任务,
因此 时间点 (HHmm) 已足够提供唯一性,无需 UUID 缀。
Args: Args:
prefix (str): 前缀,如 'manual_12345' prefix (str): 来源标识,如 'manual', 'group', 'incr', 'report'
group_name (str): 可选群名,用于日志中快速识别
Returns: Returns:
str: 格式为 'prefix-uuid'字符串 str: 语义化 TraceID 字符串
""" """
uid = str(uuid.uuid4())[:8] timestamp = datetime.now().strftime("%H%M")
return f"{prefix}-{uid}" if prefix else uid
parts: list[str] = []
if prefix:
parts.append(prefix)
if group_name:
# 清理:移除空白符和文件系统不安全字符
safe_name = re.sub(r'[\s\n\r\t/\\:*?"<>|\[\]{}]', "", group_name)
safe_name = safe_name[:_MAX_GROUP_NAME_LEN]
if safe_name:
parts.append(safe_name)
parts.append(timestamp)
return "_".join(parts)
@staticmethod
def make_report_caption() -> str:
"""
生成整洁的、面向用户的报告 Caption,包含用于去重的隐式时间戳。
该时间戳用作图片去重检查的 Token。
格式: "📊 每日群聊分析报告已生成 | MM-DD HH:MM:SS"
Returns:
str: 报告 Caption 字符串
"""
ts = datetime.now().strftime("%m-%d %H:%M:%S")
return f"📊 每日群聊分析报告已生成 | {ts}"
@classmethod @classmethod
def set(cls, trace_id: str) -> None: def set(cls, trace_id: str) -> None: