mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-09 22:04:20 +00:00
644 lines
25 KiB
Python
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)")
|