""" Langfuse LLMOps Client - Phase 15.1 + 15.3 ========================================== LLM 呼叫追蹤、成本監控、Prompt 版本管理 Phase 15.1 (2026-03-26): 基礎整合 Phase 15.3 (2026-03-26): Deep Linking (Langfuse ↔ SignOz) 端點: http://192.168.0.110:3100 (DevOps 金庫) Features: - 自動追蹤所有 LLM 呼叫 (Ollama/Gemini/Claude) - 成本估算與監控 - Prompt 版本管理 - **Phase 15.3**: OTEL Trace 整合 (SignOz Deep Link) Usage: from src.services.langfuse_client import get_langfuse, langfuse_trace # 方法 1: Context Manager (自動整合 OTEL trace_id) async with langfuse_trace("openclaw_decision") as trace: result = await call_llm(prompt) trace.generation( name="ollama_call", model="qwen2.5:7b-instruct", input=prompt, output=result, ) # trace.metadata 自動包含 signoz_trace_url # 方法 2: 裝飾器 @langfuse_observe(name="analyze_incident") async def analyze_incident(incident_id: str): ... """ from collections.abc import Callable from contextlib import asynccontextmanager from functools import wraps from typing import Any import structlog from src.core.config import settings logger = structlog.get_logger(__name__) # Langfuse client singleton _langfuse_client = None def get_langfuse(): """ 取得 Langfuse client singleton Returns: Langfuse client 或 None (如果未啟用或未配置) """ global _langfuse_client if not settings.LANGFUSE_ENABLED: return None if not settings.LANGFUSE_PUBLIC_KEY or not settings.LANGFUSE_SECRET_KEY: logger.warning( "langfuse_not_configured", message="Langfuse enabled but keys not set", ) return None if _langfuse_client is None: try: from langfuse import Langfuse _langfuse_client = Langfuse( public_key=settings.LANGFUSE_PUBLIC_KEY, secret_key=settings.LANGFUSE_SECRET_KEY, host=settings.LANGFUSE_URL, ) logger.info( "langfuse_initialized", host=settings.LANGFUSE_URL, ) except Exception as e: logger.error( "langfuse_init_failed", error=str(e), ) return None return _langfuse_client class LangfuseTraceContext: """ Langfuse Trace Context for tracking LLM calls Phase 15.3: 自動整合 OTEL trace_id,實現 Langfuse ↔ SignOz Deep Linking """ def __init__(self, name: str, metadata: dict[str, Any] | None = None): self.name = name self.metadata = metadata or {} self.trace = None self._client = get_langfuse() self._otel_trace_id: str | None = None self._langfuse_trace_id: str | None = None def __enter__(self): if self._client: try: # Phase 15.3: 取得當前 OTEL trace_id 並注入 metadata from src.core.deep_linking import DeepLinking from src.core.telemetry import get_current_trace_id self._otel_trace_id = get_current_trace_id() # 建立含 SignOz Deep Link 的 metadata enriched_metadata = {**self.metadata} if self._otel_trace_id: enriched_metadata["otel_trace_id"] = self._otel_trace_id enriched_metadata["signoz_trace_url"] = DeepLinking.signoz_trace_url( self._otel_trace_id ) # 2026-04-02 Claude Code: 移除 v4.x 死碼分支 — SDK 已鎖定 <3.0.0 # v2.x API: client.trace() 建立追蹤 self.trace = self._client.trace( name=self.name, metadata=enriched_metadata, ) if self.trace: self._langfuse_trace_id = self.trace.id logger.debug( "langfuse_trace_started", name=self.name, otel_trace_id=self._otel_trace_id, langfuse_trace_id=self._langfuse_trace_id, ) except Exception as e: logger.warning("langfuse_trace_start_failed", error=str(e)) return self def __exit__(self, exc_type, exc_val, exc_tb): # Langfuse auto-flushes, no explicit close needed pass @property def otel_trace_id(self) -> str | None: """取得關聯的 OTEL trace_id""" return self._otel_trace_id @property def langfuse_trace_id(self) -> str | None: """取得 Langfuse trace_id""" return self._langfuse_trace_id def generation( self, name: str, model: str, input: str | dict[str, Any], output: str | dict[str, Any] | None = None, usage: dict[str, int] | None = None, metadata: dict[str, Any] | None = None, ): """ 記錄一次 LLM generation Args: name: Generation 名稱 (e.g., "ollama_call", "gemini_fallback") model: 模型名稱 (e.g., "qwen2.5:7b-instruct", "gemini-1.5-flash") input: 輸入 prompt output: 輸出結果 usage: Token 使用量 {"input": x, "output": y} metadata: 額外 metadata """ # Langfuse v4.x: trace 物件可能不存在,改用 logger 記錄 if self.trace and hasattr(self.trace, "generation"): try: gen = self.trace.generation( name=name, model=model, input=input, output=output, usage=usage, metadata=metadata or {}, ) return gen except Exception as e: logger.warning( "langfuse_generation_failed", error=str(e), name=name, model=model, ) return None else: # Langfuse v4.x: 透過 OTEL 追蹤,這裡只記錄 debug log logger.debug( "langfuse_generation_logged", name=name, model=model, trace_id=self._langfuse_trace_id, ) return None def span(self, name: str, metadata: dict[str, Any] | None = None): """ 記錄一個 span (非 LLM 操作) Args: name: Span 名稱 metadata: 額外 metadata """ if self.trace and hasattr(self.trace, "span"): try: return self.trace.span(name=name, metadata=metadata or {}) except Exception as e: logger.warning("langfuse_span_failed", error=str(e), name=name) return None else: # Langfuse v4.x: OTEL spans logger.debug("langfuse_span_logged", name=name, trace_id=self._langfuse_trace_id) return None def score( self, name: str, value: float, comment: str | None = None, ): """ 記錄評分 (用於 Prompt 品質追蹤) Args: name: 評分名稱 (e.g., "response_quality", "format_compliance") value: 分數 (0.0 - 1.0) comment: 評論 """ if self.trace and hasattr(self.trace, "score"): try: self.trace.score( name=name, value=value, comment=comment, ) except Exception as e: logger.warning( "langfuse_score_failed", error=str(e), name=name, ) else: # Langfuse v4.x: score via OTEL attributes logger.debug( "langfuse_score_logged", name=name, value=value, trace_id=self._langfuse_trace_id, ) def langfuse_trace(name: str, metadata: dict[str, Any] | None = None): """ Langfuse trace context manager Usage: with langfuse_trace("openclaw_decision") as trace: result = await call_llm(prompt) trace.generation(name="ollama", model="qwen2.5:7b-instruct", ...) """ return LangfuseTraceContext(name=name, metadata=metadata) @asynccontextmanager async def langfuse_trace_async(name: str, metadata: dict[str, Any] | None = None): """ Async version of langfuse_trace Usage: async with langfuse_trace_async("openclaw_decision") as trace: result = await call_llm(prompt) """ ctx = LangfuseTraceContext(name=name, metadata=metadata) ctx.__enter__() try: yield ctx finally: ctx.__exit__(None, None, None) def langfuse_observe( name: str | None = None, metadata: dict[str, Any] | None = None, ): """ Langfuse 裝飾器 - 自動追蹤函數執行 Usage: @langfuse_observe(name="analyze_incident") async def analyze_incident(incident_id: str): ... """ def decorator(func: Callable): trace_name = name or func.__name__ @wraps(func) async def async_wrapper(*args, **kwargs): async with langfuse_trace_async(trace_name, metadata) as trace: # Inject trace into kwargs if function accepts it if "langfuse_trace" in func.__code__.co_varnames: kwargs["langfuse_trace"] = trace return await func(*args, **kwargs) @wraps(func) def sync_wrapper(*args, **kwargs): with langfuse_trace(trace_name, metadata) as trace: if "langfuse_trace" in func.__code__.co_varnames: kwargs["langfuse_trace"] = trace return func(*args, **kwargs) # Return appropriate wrapper based on function type import asyncio if asyncio.iscoroutinefunction(func): return async_wrapper return sync_wrapper return decorator def flush_langfuse(): """ 手動 flush Langfuse (通常不需要,client 會自動 flush) 用於測試或確保資料送出 """ client = get_langfuse() if client: try: client.flush() logger.debug("langfuse_flushed") except Exception as e: logger.warning("langfuse_flush_failed", error=str(e))