fix(langfuse): v4 가 옛 ingestion 을 거부 — OTLP/HTTP JSON(/api/public/otel/v1/traces)으로 전환, 로컬 Langfuse 실측 통과. 사용자·세션 속성은 자식 span 에도 복사

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-22 13:53:20 +09:00
co-authored by Claude Fable 5.1
parent 0321050053
commit 55f2e8fb60
7 changed files with 175 additions and 68 deletions
+32 -18
View File
@@ -1,4 +1,4 @@
"""Langfuse 전송 — 이벤트 모양·인증·게이트웨이/턴 마무리 훅. 서버는 MockTransport."""
"""Langfuse 전송(OTLP/HTTP JSON) — span 모양·인증·게이트웨이/턴 마무리 훅. 서버는 MockTransport."""
import asyncio
import base64
@@ -19,27 +19,38 @@ def lf_server(monkeypatch):
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": []})
return httpx.Response(200, json={})
monkeypatch.setattr(langfuse, "TRANSPORT", httpx.MockTransport(handler))
return got
def _attrs(span: dict) -> dict:
out = {}
for a in span["attributes"]:
v = a["value"]
out[a["key"]] = v.get("stringValue", v.get("intValue", v.get("boolValue")))
return out
@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
async def test_send_otlp_shape_and_basic_auth(lf_server):
root = langfuse.trace("t1", "chat", userId="u@x.com", sessionId="s1", tags=["x"])
gen = langfuse.generation("t1", "gen", model="581", usage=langfuse.usage_of(10, 5), parent=root)
assert await langfuse.send([root, gen]) and len(lf_server) == 1
req = lf_server[0]
assert req["url"] == "http://lf.test/api/public/ingestion"
assert req["url"] == "http://lf.test/api/public/otel/v1/traces"
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")
spans = req["body"]["resourceSpans"][0]["scopeSpans"][0]["spans"]
assert len(spans) == 2 and spans[0]["traceId"] == spans[1]["traceId"] and len(spans[0]["traceId"]) == 32
assert spans[1]["parentSpanId"] == spans[0]["spanId"] and "parentSpanId" not in spans[0]
ra, ga = _attrs(spans[0]), _attrs(spans[1])
assert ra["langfuse.trace.name"] == "chat" and ra["langfuse.user.id"] == "u@x.com" and ra["langfuse.session.id"] == "s1"
assert json.loads(ra["langfuse.trace.tags"]) == ["x"]
assert ga["langfuse.observation.type"] == "generation" and ga["langfuse.observation.model.name"] == "581"
assert ga["langfuse.user.id"] == "u@x.com" and ga["langfuse.session.id"] == "s1" # 자식에도 복사
assert json.loads(ga["langfuse.observation.usage_details"]) == {"input": 10, "output": 5, "total": 15}
assert int(spans[1]["endTimeUnixNano"]) >= int(spans[1]["startTimeUnixNano"])
@override_settings(LANGFUSE={"host": "", "public_key": "", "secret_key": ""})
@@ -80,8 +91,11 @@ async def test_finalize_sends_user_trace(lf_server, django_user_model, monkeypat
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"
spans = lf_server[0]["body"]["resourceSpans"][0]["scopeSpans"][0]["spans"]
t, g = _attrs(spans[0]), _attrs(spans[1])
assert t["langfuse.user.id"] == "u@x.com" and t["langfuse.session.id"] == "ses_1"
assert t["langfuse.trace.input"] == "질문" and t["langfuse.trace.output"] == ""
assert spans[1]["parentSpanId"] == spans[0]["spanId"]
assert json.loads(g["langfuse.observation.usage_details"]) == {"input": 3, "output": 5, "total": 8}
assert g["langfuse.observation.level"] == "DEFAULT" and spans[1]["status"]["code"] == 1
assert stream_mod is not None