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
+87
View File
@@ -0,0 +1,87 @@
"""Langfuse 전송 — 이벤트 모양·인증·게이트웨이/턴 마무리 훅. 서버는 MockTransport."""
import asyncio
import base64
import json
import httpx
import pytest
from django.test import override_settings
from apps.gateway import langfuse
LF = {"host": "http://lf.test", "public_key": "pk-lf-a", "secret_key": "sk-lf-b"}
@pytest.fixture
def lf_server(monkeypatch):
got: list[dict] = []
def handler(req: httpx.Request) -> httpx.Response:
got.append({"url": str(req.url), "auth": req.headers.get("authorization"), "body": json.loads(req.content)})
return httpx.Response(207, json={"successes": [], "errors": []})
monkeypatch.setattr(langfuse, "TRANSPORT", httpx.MockTransport(handler))
return got
@override_settings(LANGFUSE=LF)
async def test_send_batch_shape_and_basic_auth(lf_server):
ok = await langfuse.send([
langfuse.trace("t1", "chat", userId="u@x.com", sessionId="s1"),
langfuse.generation("t1", "gen", model="581", usage=langfuse.usage_of(10, 5)),
])
assert ok and len(lf_server) == 1
req = lf_server[0]
assert req["url"] == "http://lf.test/api/public/ingestion"
assert req["auth"] == "Basic " + base64.b64encode(b"pk-lf-a:sk-lf-b").decode()
batch = req["body"]["batch"]
assert [e["type"] for e in batch] == ["trace-create", "generation-create"]
assert batch[0]["body"] == {"id": "t1", "name": "chat", "userId": "u@x.com", "sessionId": "s1"}
assert batch[1]["body"]["traceId"] == "t1" and batch[1]["body"]["usage"] == {"input": 10, "output": 5, "total": 15}
assert batch[0]["id"] != batch[1]["id"] and batch[0]["timestamp"].endswith("Z")
@override_settings(LANGFUSE={"host": "", "public_key": "", "secret_key": ""})
async def test_disabled_is_noop(lf_server):
assert not langfuse.enabled()
assert await langfuse.send([langfuse.trace("t", "x")]) is False
langfuse.send_later([langfuse.trace("t", "x")])
assert lf_server == []
def test_collect_parses_sse_text_and_usage():
from apps.gateway.views import _Collect
c = _Collect()
c.feed(b'data: {"choices":[{"delta":{"content":"SEL"}}]}\n\ndata: {"choices":[{"delta":{"co')
c.feed(b'ntent":"ECT"}}],"usage":{"prompt_tokens":7,"completion_tokens":2}}\n\ndata: [DONE]\n\n')
assert "".join(c.text) == "SELECT" and c.usage == {"prompt_tokens": 7, "completion_tokens": 2}
@pytest.mark.django_db(transaction=True)
async def test_finalize_sends_user_trace(lf_server, django_user_model, monkeypatch):
from apps.chat import stream as stream_mod
from apps.chat.models import ChatSession
from apps.chat.stream import TurnState, _finalize
from common.opencode_service import opencode_service
async def _msgs(_sid):
return [{"info": {"role": "assistant", "tokens": {"input": 3, "output": 4, "reasoning": 1}, "time": {"created": 1000, "completed": 1500}}, "parts": [{"type": "text", "text": ""}]}]
async def _sess(_sid):
return {"title": "MARA 조회"}
monkeypatch.setattr(opencode_service, "list_messages_a", _msgs)
monkeypatch.setattr(opencode_service, "get_session_a", _sess)
user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com")
session = await ChatSession.objects.acreate(id="ses_1", user=user, is_generating=True)
with override_settings(LANGFUSE=LF):
await _finalize(session, TurnState("ses_1", "질문"), 1.0)
await asyncio.gather(*list(langfuse._pending))
assert len(lf_server) == 1
batch = lf_server[0]["body"]["batch"]
t, g = batch[0]["body"], batch[1]["body"]
assert t["userId"] == "u@x.com" and t["sessionId"] == "ses_1" and t["input"] == "질문" and t["output"] == ""
assert g["traceId"] == t["id"] and g["usage"] == {"input": 3, "output": 5, "total": 8} and g["level"] == "DEFAULT"
assert stream_mod is not None