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:
lee-hyeon-cheol
2026-09-22 13:44:12 +09:00
co-authored by Claude Fable 5.1
parent 95b169a7b9
commit 0321050053
13 changed files with 617 additions and 0 deletions
+66
View File
@@ -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")