Files
compass_backoffice/src/utils/observer.py
2026-06-13 08:23:21 -03:00

644 lines
25 KiB
Python

"""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 "<scheme> <token>" (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)")