mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-09 22:04:20 +00:00
first commit
This commit is contained in:
643
src/utils/observer.py
Normal file
643
src/utils/observer.py
Normal file
@@ -0,0 +1,643 @@
|
||||
"""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)")
|
||||
Reference in New Issue
Block a user