ForcePilot/backend/package/yuxi/channel/message/langfuse_trace.py
Kris 0b19470c70 feat(channel/message): 新增消息处理核心模块及相关工具类
本次提交新增了渠道消息处理的完整核心模块,包含以下核心功能:
1.  新增会话围栏类,实现会话并发控制与过期清理
2.  新增媒体清理器,实现过期媒体文件自动清理
3.  新增熔断器组件,实现服务降级与故障隔离
4.  新增消息处理器,完成渠道消息的完整流转处理
5.  新增限流器组件,实现渠道级和账户级流量控制
6.  新增链路追踪模块,集成Langfuse实现调用链路监控
7.  新增指标统计模块,实现消息处理全链路指标采集
8.  新增统一消息模型,封装全渠道消息格式
9.  新增块回复流水线,实现流式回复的合并与去重
10. 新增本地媒体存储模块,实现媒体文件的本地管理
11. 新增回复分发器,实现回复内容的有序发送与延迟处理
12. 完善__init__.py导出所有核心模块与工具类
2026-05-21 10:27:16 +08:00

203 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logging
import time
from typing import Any
from yuxi.channel.message.models import DispatchResult, UnifiedMessage
from yuxi.services.langfuse_service import (
get_langfuse_client,
is_langfuse_enabled,
)
logger = logging.getLogger(__name__)
class ChannelTrace:
"""渠道消息处理链路的 Langfuse trace 包装器。
复用现有 langfuse_service 的 Langfuse 客户端,创建实际 Langfuse
span 以追踪消息处理每个阶段(分发、校验、规则匹配等)的耗时和状态。
"""
def __init__(self, msg: UnifiedMessage, dispatch_result: DispatchResult | None = None):
self._msg = msg
self._dispatch_result = dispatch_result
self._client = get_langfuse_client()
self._enabled = is_langfuse_enabled() and self._client is not None
self._trace_id: str | None = None
self._trace_obj: Any = None
self._trace_start_time: float | None = None
self._live_spans: dict[str, Any] = {}
@property
def trace_name(self) -> str:
peer_kind = self._msg.sender.kind.value
return f"channel:{self._msg.channel_type}:{peer_kind}"
@property
def trace_id(self) -> str | None:
return self._trace_id
@property
def span_count(self) -> int:
return len(self._live_spans)
async def start(self) -> "ChannelTrace":
if not self._enabled:
return self
try:
self._trace_start_time = time.monotonic()
self._trace_id = self._client.create_trace_id()
self._trace_obj = self._client.trace(
id=self._trace_id,
name=self.trace_name,
metadata=self._build_base_metadata(),
tags=self._build_base_tags(),
)
except Exception:
logger.exception("Failed to create channel trace: %s", self.trace_name)
self._enabled = False
return self
async def add_span(
self,
name: str,
metadata: dict[str, Any] | None = None,
input_data: Any = None,
output_data: Any = None,
level: str = "DEFAULT",
status_message: str | None = None,
parent_span_id: str | None = None,
) -> str | None:
"""创建 Langfuse span 并追踪其生命周期。
返回 span_id 供后续 end_span() 结束使用。
调用者必须在完成处理后调用 end_span() 或依赖 finish() 统一结束。
"""
if not self._enabled or self._trace_obj is None:
return None
try:
parent = self._live_spans.get(parent_span_id) if parent_span_id else self._trace_obj
span = parent.span(
name=name,
input=input_data,
output=output_data,
metadata=metadata or {},
level=level,
status_message=status_message,
)
span_id: str = span.id
self._live_spans[span_id] = span
return span_id
except Exception:
logger.exception("Failed to create span '%s' for trace: %s", name, self.trace_name)
return None
async def end_span(
self,
span_id: str,
output_data: Any = None,
status_message: str | None = None,
level: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
"""结束指定 span记录输出与最终状态。"""
span = self._live_spans.pop(span_id, None)
if span is None:
return
try:
update_kwargs: dict[str, Any] = {}
if output_data is not None:
update_kwargs["output"] = output_data
if level is not None:
update_kwargs["level"] = level
if status_message is not None:
update_kwargs["status_message"] = status_message
if metadata is not None:
update_kwargs["metadata"] = metadata
if update_kwargs:
span.update(**update_kwargs)
span.end()
except Exception:
logger.exception("Failed to end span '%s' for trace: %s", span_id, self.trace_name)
async def finish(self, error: str | None = None) -> None:
if not self._enabled or self._trace_obj is None:
return
try:
pending = dict(self._live_spans)
self._live_spans.clear()
for span_id, span in pending.items():
try:
span.end()
except Exception:
logger.exception("Failed to end pending span '%s'", span_id)
duration_ms = None
if self._trace_start_time is not None:
duration_ms = (time.monotonic() - self._trace_start_time) * 1000
metadata: dict[str, Any] = {
**self._build_base_metadata(),
"span_count": len(pending),
"dispatch_success": error is None,
**(self._build_dispatch_metadata() if self._dispatch_result else {}),
}
if duration_ms is not None:
metadata["duration_ms"] = round(duration_ms, 2)
self._trace_obj.update(
output=error or "success",
metadata=metadata,
)
except Exception:
logger.exception("Failed to finish channel trace: %s", self.trace_name)
def _build_base_metadata(self) -> dict[str, Any]:
return {
"source": "channel",
"channel_type": self._msg.channel_type,
"account_id": self._msg.account_id,
"peer_kind": self._msg.sender.kind.value,
"peer_id": self._msg.sender.id,
"msg_id": self._msg.msg_id,
"message_type": self._msg.message_type.value,
"feature": "channel_dispatch",
}
def _build_dispatch_metadata(self) -> dict[str, Any]:
if self._dispatch_result is None:
return {}
return {
"agent_config_id": str(self._dispatch_result.agent_config_id)
if self._dispatch_result.agent_config_id
else None,
"session_key": getattr(self._dispatch_result, "session_key", None),
"thread_id": self._dispatch_result.thread_id,
"matched_by": getattr(self._dispatch_result, "matched_by", None),
}
def _build_base_tags(self) -> list[str]:
tags = [
"yuxi",
"channel",
f"channel:{self._msg.channel_type}",
f"peer_kind:{self._msg.sender.kind.value}",
]
if self._dispatch_result and self._dispatch_result.agent_config_id:
tags.append(f"agent_config:{self._dispatch_result.agent_config_id}")
return tags
async def create_channel_trace(
msg: UnifiedMessage,
dispatch_result: DispatchResult | None = None,
) -> ChannelTrace:
trace = ChannelTrace(msg, dispatch_result)
await trace.start()
return trace