"""Configuração do Observer SDK e tracing Langfuse via SDK. Arquitetura dual: - Observer (agent_framework) coleta ICs independentemente via monkey_patch_observer() - Langfuse SDK com @observe instrumenta nós automaticamente via OTEL - ICs coletados são anexados como metadata ao span ativo via set_span_attribute O decorator @trace_node envolve nós com @observe do SDK e anexa ICs como metadata. """ from __future__ import annotations import asyncio import os from contextvars import ContextVar from functools import wraps from typing import Any, Callable, Optional from src.compat.framework_observer import configure from src.utils.ics_collector import ICsCollector, monkey_patch_observer from src.core.logging import get_logger, log_operation logger = get_logger(__name__) # ContextVar preenchido pelos clients HTTP antes de retornar. # trace_tool lê esse valor para enriquecer o output da observation. _tool_call_metadata: ContextVar[dict] = ContextVar("_tool_call_metadata", default={}) _SENSITIVE_HEADER_NAMES = frozenset({ "authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", "api-key", "apikey", }) def _mask_sensitive_value(value: str, prefix: int = 8, suffix: int = 4) -> str: """Mascara parcialmente um valor sensível, preservando início e fim. Para headers Authorization no formato " " (ex: "Bearer xyz"), o esquema é preservado e apenas o token é mascarado parcialmente. """ if not value: return "***" scheme, separator, token = value.partition(" ") if separator and token: if len(token) <= prefix + suffix: return f"{scheme} ***" return f"{scheme} {token[:prefix]}...{token[-suffix:]}" if len(value) <= prefix + suffix: return "***" return f"{value[:prefix]}...{value[-suffix:]}" def _redact_headers(headers: Any) -> Optional[dict]: """Return a dict copy of headers with sensitive values partially masked. Accepts httpx.Headers, dict, or any iterable of (key, value) pairs. Returns None when no headers are provided. """ if headers is None: return None try: items = headers.items() if hasattr(headers, "items") else list(headers) except Exception: return None redacted: dict[str, str] = {} for raw_key, raw_value in items: key_str = str(raw_key) if key_str.lower() in _SENSITIVE_HEADER_NAMES: redacted[key_str] = _mask_sensitive_value(str(raw_value)) else: redacted[key_str] = str(raw_value) return redacted def set_tool_call_metadata( endpoint: str, status_code: Optional[int], response_body: Any = None, *, method: Optional[str] = None, request_headers: Any = None, request_body: Any = None, response_headers: Any = None, timeout: Any = None, latency_ms: Optional[float] = None, http_version: Optional[str] = None, ) -> None: """Registra metadados da chamada HTTP para o trace_tool capturar. Deve ser chamado pelo client HTTP antes de retornar o resultado, dentro do método decorado com @trace_tool. Os campos extras (method, headers, timeout, latency) são opcionais para manter compatibilidade com chamadas legadas, mas devem ser preenchidos pelos clients http para enriquecer o trace no Langfuse. Args: endpoint: URL completa consultada. status_code: HTTP status code recebido (None em caso de erro de transporte). response_body: Corpo da resposta já deserializado (dict/list). method: Método HTTP (GET, POST, ...). request_headers: Headers enviados (httpx.Headers ou dict). Segredos são redigidos. response_headers: Headers recebidos. Segredos são redigidos. timeout: Timeout efetivo do request (qualquer tipo serializável). latency_ms: Latência medida em milissegundos. http_version: Versão HTTP da resposta (ex: "HTTP/1.1"). """ _tool_call_metadata.set({ "endpoint": endpoint, "status_code": status_code, "response_body": response_body, "method": method, "request_headers": _redact_headers(request_headers), "request_body": request_body, "response_headers": _redact_headers(response_headers), "timeout": _serialize_timeout(timeout), "latency_ms": latency_ms, "http_version": http_version, }) def _serialize_timeout(timeout: Any) -> Any: """Best-effort serialization for httpx.Timeout or scalar timeouts.""" if timeout is None: return None if isinstance(timeout, (int, float, str, bool)): return timeout parts = {} for attr in ("connect", "read", "write", "pool"): value = getattr(timeout, attr, None) if value is not None: parts[attr] = value if parts: return parts try: return str(timeout) except Exception: return None def _serialize_result(result: Any) -> Any: """Serializa o retorno da função para um formato compatível com Langfuse.""" if hasattr(result, "model_dump"): return result.model_dump() if hasattr(result, "dict"): return result.dict() return result def _extract_session_id(state: Any) -> Optional[str]: """Extrai session_id do AgentState.""" try: if isinstance(state, dict): if state.get("session_id"): return state["session_id"] metadata = state.get("metadata") else: val = getattr(state, "session_id", None) if val: return val metadata = getattr(state, "metadata", None) if metadata is not None: if hasattr(metadata, "session_id") and metadata.session_id: return metadata.session_id if isinstance(metadata, dict) and metadata.get("session_id"): return metadata["session_id"] return None except Exception: return None def trace_node(func: Callable[..., Any]) -> Callable[..., Any]: """Decorator que instrumenta nós com Langfuse SDK e anexa ICs. Usa langfuse.get_client().start_as_current_observation para criar spans automaticamente com hierarquia correta. Ao final da execução, anexa os ICs coletados como metadata no span. Se o SDK não estiver disponível, opera como no-op transparente. Args: func: Função de nó com assinatura (state: AgentState) -> AgentState. Returns: Função envolvida com a mesma assinatura. """ module = func.__module__ or "" module_short = module.split(".")[-1] func_name = func.__name__ if module_short and module_short != "__main__": node_name = module_short else: node_name = func_name is_async = asyncio.iscoroutinefunction(func) if is_async: @wraps(func) async def async_wrapper(state: Any) -> Any: session_id = _extract_session_id(state) async with log_operation(func_name, component=node_name, logger=logger) as op: if isinstance(state, dict): messages = state.get("messages") if messages is not None: op.add_field("message_count", len(messages)) iteration = state.get("iteration_count") if iteration is not None: op.add_field("iteration_count", iteration) try: from langfuse import get_client lf = get_client() with lf.start_as_current_observation( as_type="span", name=node_name, input=state if isinstance(state, dict) else None, ) as obs: result = await func(state) ics = ICsCollector.get_current(session_id) if session_id else [] logger.debug("trace_node %s: session=%s ics_count=%d", node_name, session_id, len(ics)) error_in_state = result.get("error") if isinstance(result, dict) else None if error_in_state: obs.update( output=result, level="ERROR", status_message=f"[{error_in_state.get('type', 'Error')}] {error_in_state.get('message', '')}", metadata={"ics": ics, "session_id": session_id}, ) else: obs.update( output=result if isinstance(result, dict) else None, metadata={"ics": ics, "session_id": session_id}, ) return result except ImportError: return await func(state) return async_wrapper else: @wraps(func) def sync_wrapper(state: Any) -> Any: session_id = _extract_session_id(state) with log_operation(func_name, component=node_name, logger=logger) as op: if isinstance(state, dict): messages = state.get("messages") if messages is not None: op.add_field("message_count", len(messages)) iteration = state.get("iteration_count") if iteration is not None: op.add_field("iteration_count", iteration) try: from langfuse import get_client lf = get_client() with lf.start_as_current_observation( as_type="span", name=node_name, input=state if isinstance(state, dict) else None, ) as obs: result = func(state) ics = ICsCollector.get_current(session_id) if session_id else [] logger.debug("trace_node %s: session=%s ics_count=%d", node_name, session_id, len(ics)) error_in_state = result.get("error") if isinstance(result, dict) else None if error_in_state: obs.update( output=result, level="ERROR", status_message=f"[{error_in_state.get('type', 'Error')}] {error_in_state.get('message', '')}", metadata={"ics": ics, "session_id": session_id}, ) else: obs.update( output=result if isinstance(result, dict) else None, metadata={"ics": ics, "session_id": session_id} if ics else {"session_id": session_id}, ) return result except ImportError: return func(state) return sync_wrapper def trace_api(func: Callable[..., Any]) -> Callable[..., Any]: """Decorator que registra chamadas de API externa (Siebel, IMDB) no Langfuse. Cria um span as_type="tool" para dar visibilidade às chamadas externas. """ is_async = asyncio.iscoroutinefunction(func) api_name = func.__name__ module_short = (func.__module__ or "").split(".")[-1] if is_async: @wraps(func) async def async_wrapper(*args: Any, **kwargs: Any) -> Any: async with log_operation(api_name, component=module_short, logger=logger): try: from langfuse import get_client lf = get_client() with lf.start_as_current_observation( as_type="tool", name=api_name, input={"args": list(args), "kwargs": kwargs} if (args or kwargs) else None, ) as obs: try: result = await func(*args, **kwargs) obs.update(output=result) return result except Exception as exc: obs.update(level="ERROR", status_message=str(exc)) raise except ImportError: return await func(*args, **kwargs) return async_wrapper else: @wraps(func) def sync_wrapper(*args: Any, **kwargs: Any) -> Any: with log_operation(api_name, component=module_short, logger=logger): try: from langfuse import get_client lf = get_client() with lf.start_as_current_observation( as_type="tool", name=api_name, input={"args": list(args), "kwargs": kwargs} if (args or kwargs) else None, ) as obs: try: result = func(*args, **kwargs) obs.update(output=result) return result except Exception as exc: obs.update(level="ERROR", status_message=str(exc)) raise except ImportError: return func(*args, **kwargs) return sync_wrapper def trace_tool(func: Callable[..., Any]) -> Callable[..., Any]: """Decorator que registra chamadas de tool como observation no Langfuse. Aplica em _run e _arun de qualquer BaseTool. Cria um span as_type="tool" filho do span ativo (nó que chamou a tool), registrando input, output e erros. Args: func: Método _run ou _arun da tool. Returns: Método envolvido com a mesma assinatura. """ is_async = asyncio.iscoroutinefunction(func) method_name = func.__name__ if is_async: @wraps(func) async def async_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: tool_name = f"{self.__class__.__name__}.{method_name}" filtered_kwargs = {key: value for key, value in kwargs.items() if key != "run_manager"} obs_input = { "args": list(args), **filtered_kwargs, } if (args or filtered_kwargs) else None async with log_operation(method_name, component=self.__class__.__name__, logger=logger): try: from langfuse import get_client lf = get_client() with lf.start_as_current_observation( as_type="tool", name=tool_name, input=obs_input, ) as obs: try: result = await func(self, *args, **kwargs) metadata = _tool_call_metadata.get({}) _tool_call_metadata.set({}) update_http_observation( obs, metadata, obs_input, result=result, ) return result except Exception as exc: metadata = _tool_call_metadata.get({}) _tool_call_metadata.set({}) update_http_observation( obs, metadata, obs_input, exc=exc, ) _flush_failed_tool_span(lf) raise except ImportError: return await func(self, *args, **kwargs) return async_wrapper else: @wraps(func) def sync_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: tool_name = f"{self.__class__.__name__}.{method_name}" filtered_kwargs = {key: value for key, value in kwargs.items() if key != "run_manager"} obs_input = { "args": list(args), **filtered_kwargs, } if (args or filtered_kwargs) else None with log_operation(method_name, component=self.__class__.__name__, logger=logger): try: from langfuse import get_client lf = get_client() with lf.start_as_current_observation( as_type="tool", name=tool_name, input=obs_input, ) as obs: try: result = func(self, *args, **kwargs) metadata = _tool_call_metadata.get({}) _tool_call_metadata.set({}) update_http_observation( obs, metadata, obs_input, result=result, ) return result except Exception as exc: metadata = _tool_call_metadata.get({}) _tool_call_metadata.set({}) update_http_observation( obs, metadata, obs_input, exc=exc, ) _flush_failed_tool_span(lf) raise except ImportError: return func(self, *args, **kwargs) return sync_wrapper def _flush_failed_tool_span(lf: Any) -> None: """Force-flush the just-ended failed tool span to Langfuse. Why: when a tool raises, the OTEL BatchSpanProcessor buffers the span. If the request completes (handler returns) before the next batch is exported, the error span is dropped — making timeout/error retries invisible in Langfuse while the eventual successful retry shows up because the parent trace's later flush_trace() catches it. Doing a targeted flush here keeps error visibility deterministic. """ try: from opentelemetry import trace as otel_trace tracer_provider = otel_trace.get_tracer_provider() if hasattr(tracer_provider, "force_flush"): tracer_provider.force_flush(timeout_millis=2000) except Exception: pass try: if lf is not None: lf.flush() except Exception: pass def update_http_observation( obs: Any, metadata: dict, obs_input: Optional[dict], *, result: Any = None, exc: Optional[BaseException] = None, ) -> None: """Enrich the active observation with captured HTTP metadata. Input merges the tool's original args/kwargs with the HTTP request side (endpoint, method, headers, body, timeout). Output carries the HTTP response side (status, headers, body, latency, version) plus, on error, an `error` field. Tools without HTTP traffic fall back to the minimal args/result shape. """ has_http = bool(metadata) and metadata.get("endpoint") is not None if has_http: new_input: dict = dict(obs_input) if obs_input else {} new_input.update({ "endpoint": metadata.get("endpoint"), "method": metadata.get("method"), "request_headers": metadata.get("request_headers"), "request_body": metadata.get("request_body"), "timeout": metadata.get("timeout"), }) new_output: dict = { "status_code": metadata.get("status_code"), "response_headers": metadata.get("response_headers"), "latency_ms": metadata.get("latency_ms"), "http_version": metadata.get("http_version"), "response": metadata.get("response_body"), } if exc is not None: new_output["error"] = str(exc) obs.update( input=new_input, output=new_output, level="ERROR", status_message=f"[{type(exc).__name__}] {exc}", ) else: obs.update(input=new_input, output=new_output) return if exc is not None: obs.update( level="ERROR", status_message=f"[{type(exc).__name__}] {exc}", output={"error": str(exc)}, ) else: obs.update(output=_serialize_result(result)) def score_current_trace(name: str, value: float, comment: str = "") -> None: """Registra um score no trace ativo do Langfuse. Extrai o trace_id do contexto OTEL ativo e associa o score ao trace correto. Se o SDK não estiver disponível ou não houver span ativo, opera como no-op silencioso. Args: name: Nome do score (ex: "decision_valid", "triplet_valid"). value: Valor numérico do score (0.0 a 1.0). comment: Comentário opcional. """ try: from opentelemetry import trace as otel_trace from langfuse import get_client current_span = otel_trace.get_current_span() span_context = current_span.get_span_context() if not span_context or not span_context.is_valid: logger.warning("score_current_trace(%s): nenhum span OTEL ativo, score descartado", name) return trace_id = format(span_context.trace_id, "032x") logger.info("score_current_trace(%s): trace_id=%s value=%s", name, trace_id, value) lf = get_client() lf.create_score( trace_id=trace_id, name=name, value=value, comment=comment, ) logger.info("score_current_trace(%s): create_score() chamado com sucesso", name) except Exception as exc: logger.error("score_current_trace(%s): erro ao registrar score: %s", name, exc, exc_info=True) def flush_trace() -> None: """Força o envio imediato de todos os spans e logs pendentes. Deve ser chamado após o encerramento de um trace, especialmente em fluxos que terminam rapidamente (ex: erros de validação sem chamadas LLM), onde o BatchSpanProcessor pode não ter feito flush automático ainda. """ try: from opentelemetry import trace as otel_trace tracer_provider = otel_trace.get_tracer_provider() if hasattr(tracer_provider, "force_flush"): tracer_provider.force_flush(timeout_millis=5000) except Exception: pass # Flush OTel log records (NOC events via BatchLogRecordProcessor). # Without this, events sit in the buffer for up to 5 s after the request. try: from opentelemetry._logs import get_logger_provider lp = get_logger_provider() if hasattr(lp, "force_flush"): lp.force_flush(timeout_millis=2000) except Exception: pass # Flush the Langfuse SDK's internal score/event queue separately — # lf.score() goes through the SDK task manager, not through OTEL spans. try: from langfuse import get_client lf = get_client() lf.flush() except Exception: pass def _build_langfuse_httpx_client(): """Build an httpx.Client honoring settings.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE.""" from src.core.config import settings cert_path = settings.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE if not cert_path: return None try: import httpx return httpx.Client(verify=cert_path) except Exception as exc: logger.warning("Failed to build httpx client for Langfuse: %s", exc) return None def setup_observer() -> None: """Configura o Observer SDK e inicializa o Langfuse SDK. O Observer coleta ICs via monkey_patch_observer(). O Langfuse SDK lê LANGFUSE_PUBLIC_KEY/SECRET_KEY/HOST do ambiente e instrumenta automaticamente via OTEL. """ configure({ "publisher": {"buffer_size": 10}, "sampling_rate": 1.0, "log_level": "INFO", }) monkey_patch_observer() # Inicializa o SDK — lê credenciais do ambiente automaticamente try: from src.core.config import settings if settings.LANGFUSE_PUBLIC_KEY: os.environ.setdefault("LANGFUSE_PUBLIC_KEY", settings.LANGFUSE_PUBLIC_KEY) if settings.LANGFUSE_SECRET_KEY: os.environ.setdefault("LANGFUSE_SECRET_KEY", settings.LANGFUSE_SECRET_KEY) if settings.LANGFUSE_BASE_URL: # Re-use the already setup host from settings.setup_langfuse() os.environ.setdefault("LANGFUSE_HOST", os.environ.get("LANGFUSE_HOST") or settings.LANGFUSE_BASE_URL) os.environ.setdefault( "OTEL_EXPORTER_OTLP_ENDPOINT", (settings.LANGFUSE_BASE_URL).rstrip("/") + "/api/public/otel", ) logger.debug(f"Initializing Langfuse SDK at: {settings.LANGFUSE_BASE_URL}") from langfuse import Langfuse httpx_client = _build_langfuse_httpx_client() if httpx_client is not None: Langfuse(httpx_client=httpx_client) logger.info("Langfuse SDK initialized with custom httpx client (OTLP certificate)") else: Langfuse() logger.info("Langfuse SDK initialized") except Exception as e: logger.debug("Langfuse SDK not available: %s", e) logger.info("Observer configured (Langfuse SDK + @observe)")