Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
86 lines
3.2 KiB
Python
86 lines
3.2 KiB
Python
"""Langfuse 로 trace/generation 쏘기 — SDK 없이 HTTP 한 방 (docs-lib/langfuse.md).
|
|
|
|
LANGFUSE_HOST 가 비어 있으면 전부 no-op. 보내는 건 fire-and-forget: 실패해도 채팅엔 영향 0, 로그만.
|
|
두 군데서 부름:
|
|
- apps/chat/stream.py _finalize → 사용자 단위 trace(누가·어느 세션·질문·최종 답·토큰·시간)
|
|
- apps/gateway/views.py → FabriX 호출 단위 generation(모델·프롬프트 원문·응답·상태)
|
|
둘은 서로 모름(OpenCode 가 사이에 있어 사용자 정보가 게이트웨이까지 안 옴). 나중에 잇고 싶으면 sessionId 로 대조.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
import httpx
|
|
from django.conf import settings
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
TRANSPORT: httpx.AsyncBaseTransport | None = None # 테스트가 MockTransport 꽂는 자리
|
|
_pending: set[asyncio.Task] = set() # GC 에 안 먹히게 잡아둠
|
|
|
|
|
|
def enabled() -> bool:
|
|
return bool(settings.LANGFUSE.get("host"))
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _event(kind: str, body: dict) -> dict:
|
|
return {"id": str(uuid.uuid4()), "timestamp": now_iso(), "type": kind, "body": body}
|
|
|
|
|
|
def trace(trace_id: str, name: str, **body) -> dict:
|
|
return _event("trace-create", {"id": trace_id, "name": name, **body})
|
|
|
|
|
|
def generation(trace_id: str, name: str, **body) -> dict:
|
|
return _event("generation-create", {"id": str(uuid.uuid4()), "traceId": trace_id, "name": name, **body})
|
|
|
|
|
|
def usage_of(inp: int | None, out: int | None) -> dict | None:
|
|
if inp is None and out is None:
|
|
return None
|
|
return {"input": inp or 0, "output": out or 0, "total": (inp or 0) + (out or 0)}
|
|
|
|
|
|
async def send(events: list[dict]) -> bool:
|
|
"""배치 하나 전송. 207 이면 성공(부분 실패는 로그)."""
|
|
cfg = settings.LANGFUSE
|
|
if not cfg.get("host") or not events:
|
|
return False
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0, transport=TRANSPORT) as client:
|
|
resp = await client.post(
|
|
cfg["host"].rstrip("/") + "/api/public/ingestion",
|
|
json={"batch": events},
|
|
auth=(cfg.get("public_key", ""), cfg.get("secret_key", "")),
|
|
)
|
|
if resp.status_code not in (200, 207):
|
|
log.warning("langfuse ← %s %s", resp.status_code, resp.text[:200])
|
|
return False
|
|
errors = (resp.json() or {}).get("errors") or []
|
|
if errors:
|
|
log.warning("langfuse 일부 실패: %s", errors[:3])
|
|
return not errors
|
|
except Exception as e: # noqa: BLE001 — 관측용이라 절대 본 흐름 안 깨뜨림
|
|
log.warning("langfuse 전송 실패: %s: %s", type(e).__name__, e)
|
|
return False
|
|
|
|
|
|
def send_later(events: list[dict]) -> None:
|
|
"""지금 흐름 안 막고 백그라운드로. 이벤트 루프 없으면(동기 테스트) 조용히 버림."""
|
|
if not enabled() or not events:
|
|
return
|
|
try:
|
|
task = asyncio.get_running_loop().create_task(send(events))
|
|
except RuntimeError:
|
|
return
|
|
_pending.add(task)
|
|
task.add_done_callback(_pending.discard)
|