feat(observability): Langfuse 전송 — 턴 마무리(사용자 trace)·게이트웨이(FabriX generation) 두 훅, SDK 없이 HTTP. deploy/langfuse 에 compose+env+반입 절차
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
95b169a7b9
commit
0321050053
@@ -0,0 +1,85 @@
|
||||
"""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)
|
||||
@@ -11,6 +11,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
@@ -19,6 +22,7 @@ from django.conf import settings
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from . import langfuse
|
||||
from .fabrix import FabrixConfig, Variant, auth_variants
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -60,6 +64,57 @@ def _sse_error(status: int, detail: bytes) -> bytes:
|
||||
return b"data: " + json.dumps(payload, ensure_ascii=False).encode() + b"\n\ndata: [DONE]\n\n"
|
||||
|
||||
|
||||
class _Collect:
|
||||
"""상류 응답에서 답변 텍스트·usage 만 긁어 모음(관측용). 스트림은 줄 단위 `data: {json}`."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.text: list[str] = []
|
||||
self.usage: dict | None = None
|
||||
self.buf = b""
|
||||
|
||||
def feed(self, chunk: bytes) -> None:
|
||||
self.buf += chunk
|
||||
while b"\n" in self.buf:
|
||||
line, self.buf = self.buf.split(b"\n", 1)
|
||||
self._line(line.strip())
|
||||
|
||||
def _line(self, line: bytes) -> None:
|
||||
if not line.startswith(b"data:") or line.endswith(b"[DONE]"):
|
||||
return
|
||||
try:
|
||||
d = json.loads(line[5:])
|
||||
except ValueError:
|
||||
return
|
||||
self.json(d)
|
||||
|
||||
def json(self, d: dict) -> None:
|
||||
for c in d.get("choices") or []:
|
||||
t = (c.get("delta") or c.get("message") or {}).get("content")
|
||||
if isinstance(t, str):
|
||||
self.text.append(t)
|
||||
if d.get("usage"):
|
||||
self.usage = d["usage"]
|
||||
|
||||
|
||||
def _observe(model_id: str, body: dict, col: _Collect, started: float, status: int, *, kinds: list[str]) -> None:
|
||||
if not langfuse.enabled():
|
||||
return
|
||||
u = col.usage or {}
|
||||
tid = str(uuid.uuid4())
|
||||
langfuse.send_later([
|
||||
langfuse.trace(tid, "fabrix", tags=["gateway"], metadata={"parts": kinds}),
|
||||
langfuse.generation(
|
||||
tid, "fabrix.chat", model=model_id,
|
||||
startTime=datetime.fromtimestamp(started, timezone.utc).isoformat(), endTime=langfuse.now_iso(),
|
||||
input=body.get("messages"), output="".join(col.text),
|
||||
usage=langfuse.usage_of(u.get("prompt_tokens"), u.get("completion_tokens")),
|
||||
level="ERROR" if status >= 400 else "DEFAULT", statusMessage="" if status < 400 else f"upstream {status}",
|
||||
modelParameters={k: v for k, v in body.items() if k in ("temperature", "max_completion_tokens", "max_tokens")},
|
||||
metadata={"stream": bool(body.get("stream")), "tools": len(body.get("tools") or [])},
|
||||
),
|
||||
])
|
||||
|
||||
|
||||
async def healthcheck(_request: HttpRequest) -> JsonResponse:
|
||||
return JsonResponse({"success": True})
|
||||
|
||||
@@ -95,6 +150,9 @@ async def chat_completions(request: HttpRequest) -> HttpResponse:
|
||||
# FabriX 가 맞는 조합에도 가끔 401 을 뱉음(2026-09-21 고객사 실측: curl 10번 중 1~2번).
|
||||
# 4개 조합 다 돌고 나서 제일 유력한 조합(맨 앞)을 잠깐 쉬고 한 번 더 — 그래도 401 이면 진짜 인증 문제
|
||||
variants = variants + variants[:1]
|
||||
model_id = headers.get("x-llm-model-id", "")
|
||||
started = time.time()
|
||||
col = _Collect()
|
||||
|
||||
if body.get("stream"):
|
||||
|
||||
@@ -119,9 +177,11 @@ async def chat_completions(request: HttpRequest) -> HttpResponse:
|
||||
return
|
||||
_last_ok["variant"] = vkey
|
||||
async for chunk in resp.aiter_raw():
|
||||
col.feed(chunk)
|
||||
yield chunk
|
||||
finally:
|
||||
await resp.aclose()
|
||||
_observe(model_id, body, col, started, resp.status_code, kinds=kinds or ["text"])
|
||||
|
||||
out = StreamingHttpResponse(gen(), content_type="text/event-stream")
|
||||
out["Cache-Control"] = "no-cache"
|
||||
@@ -141,4 +201,10 @@ async def chat_completions(request: HttpRequest) -> HttpResponse:
|
||||
break
|
||||
except httpx.HTTPError as e:
|
||||
return _err(502, f"FabriX 호출 실패: {type(e).__name__}: {e}", "upstream_error")
|
||||
if resp.status_code < 400:
|
||||
try:
|
||||
col.json(resp.json())
|
||||
except ValueError:
|
||||
pass
|
||||
_observe(model_id, body, col, started, resp.status_code, kinds=kinds or ["text"])
|
||||
return HttpResponse(resp.content, status=resp.status_code, content_type="application/json")
|
||||
|
||||
Reference in New Issue
Block a user