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
+103 -20
View File
@@ -1,5 +1,6 @@
"""Langfuse 로 trace/generation 쏘기 — SDK 없이 HTTP 한 방 (docs-lib/langfuse.md).
"""Langfuse 로 trace/generation 쏘기 — SDK 없이 OTLP/HTTP JSON 한 방 (docs-lib/langfuse.md).
v4 는 옛 /api/public/ingestion 이 막혀서(score 만) OTel 엔드포인트로 감. 트레이스 하나 = 루트 span(trace 속성) + 자식 span(generation).
LANGFUSE_HOST 가 비어 있으면 전부 no-op. 보내는 건 fire-and-forget: 실패해도 채팅엔 영향 0, 로그만.
두 군데서 부름:
- apps/chat/stream.py _finalize → 사용자 단위 trace(누가·어느 세션·질문·최종 답·토큰·시간)
@@ -10,7 +11,10 @@ LANGFUSE_HOST 가 비어 있으면 전부 no-op. 보내는 건 fire-and-forget:
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import time
import uuid
from datetime import datetime, timezone
@@ -31,16 +35,86 @@ 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_hex(trace_id: str) -> str:
"""아무 문자열 → OTel traceId(16바이트 hex). 같은 문자열이면 같은 trace 로 묶임."""
return hashlib.sha256(trace_id.encode()).hexdigest()[:32]
def trace(trace_id: str, name: str, **body) -> dict:
return _event("trace-create", {"id": trace_id, "name": name, **body})
def _nanos(iso_or_none: str | None) -> str:
if not iso_or_none:
return str(time.time_ns())
dt = datetime.fromisoformat(iso_or_none.replace("Z", "+00:00"))
return str(int(dt.timestamp() * 1_000_000_000))
def generation(trace_id: str, name: str, **body) -> dict:
return _event("generation-create", {"id": str(uuid.uuid4()), "traceId": trace_id, "name": name, **body})
def _attr(k: str, v) -> dict | None:
if v is None or v == "" or v == {} or v == []:
return None
if isinstance(v, bool):
return {"key": k, "value": {"boolValue": v}}
if isinstance(v, int):
return {"key": k, "value": {"intValue": str(v)}}
if isinstance(v, float):
return {"key": k, "value": {"doubleValue": v}}
if isinstance(v, str):
return {"key": k, "value": {"stringValue": v}}
return {"key": k, "value": {"stringValue": json.dumps(v, ensure_ascii=False)}}
def _span(trace_id: str, name: str, attrs: dict, *, start: str | None, end: str | None, parent: str | None, error: str | None) -> dict:
span = {
"traceId": _trace_hex(trace_id),
"spanId": uuid.uuid4().hex[:16],
"name": name,
"kind": 1, # INTERNAL
"startTimeUnixNano": _nanos(start),
"endTimeUnixNano": _nanos(end),
"attributes": [a for a in (_attr(k, v) for k, v in attrs.items()) if a],
"status": {"code": 2, "message": error} if error else {"code": 1},
}
if parent:
span["parentSpanId"] = parent
return span
def trace(trace_id: str, name: str, *, userId: str = "", sessionId: str = "", input=None, output=None,
metadata: dict | None = None, tags: list[str] | None = None, start: str | None = None, end: str | None = None) -> dict:
"""루트 span. trace 속성(이름·사용자·세션·태그)은 여기 실림."""
attrs = {
"langfuse.trace.name": name,
"langfuse.observation.type": "span",
"langfuse.user.id": userId,
"langfuse.session.id": sessionId,
"langfuse.trace.input": input,
"langfuse.trace.output": output,
"langfuse.trace.tags": tags,
**{f"langfuse.trace.metadata.{k}": v for k, v in (metadata or {}).items()},
}
return _span(trace_id, name, attrs, start=start, end=end, parent=None, error=None)
def generation(trace_id: str, name: str, *, model: str = "", input=None, output=None, usage: dict | None = None,
level: str = "DEFAULT", statusMessage: str = "", metadata: dict | None = None,
modelParameters: dict | None = None, startTime: str | None = None, endTime: str | None = None,
parent: dict | None = None) -> dict:
"""generation span. parent 로 trace() 결과를 주면 그 밑에 붙고, 없으면 같은 traceId 의 루트.
사용자·세션·이름·태그는 자식에도 복사 — Langfuse 가 필터·집계할 때 span 마다 보기 때문(로컬 실측: 안 하면 빈 값)."""
inherited = {a["key"]: a["value"].get("stringValue") for a in (parent or {}).get("attributes", [])
if a["key"] in ("langfuse.user.id", "langfuse.session.id", "langfuse.trace.name", "langfuse.trace.tags")}
attrs = {
**inherited,
"langfuse.observation.type": "generation",
"langfuse.observation.model.name": model,
"langfuse.observation.input": input,
"langfuse.observation.output": output,
"langfuse.observation.usage_details": usage,
"langfuse.observation.model_parameters": modelParameters,
"langfuse.observation.level": level,
"langfuse.observation.status_message": statusMessage,
**{f"langfuse.observation.metadata.{k}": v for k, v in (metadata or {}).items()},
}
return _span(trace_id, name, attrs, start=startTime, end=endTime,
parent=parent["spanId"] if parent else None, error=statusMessage if level == "ERROR" else None)
def usage_of(inp: int | None, out: int | None) -> dict | None:
@@ -49,36 +123,45 @@ def usage_of(inp: int | None, out: int | None) -> dict | 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 이면 성공(부분 실패는 로그)."""
def _otlp_body(spans: list[dict]) -> dict:
return {"resourceSpans": [{
"resource": {"attributes": [_attr("service.name", "codeassist-backend")]},
"scopeSpans": [{"scope": {"name": "codeassist"}, "spans": spans}],
}]}
async def send(spans: list[dict]) -> bool:
"""span 묶음 하나 전송. OTLP 는 200 + 빈 JSON 이면 성공, partialSuccess 있으면 일부 실패."""
cfg = settings.LANGFUSE
if not cfg.get("host") or not events:
if not cfg.get("host") or not spans:
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},
cfg["host"].rstrip("/") + "/api/public/otel/v1/traces",
json=_otlp_body(spans),
headers={"x-langfuse-ingestion-version": "4"},
auth=(cfg.get("public_key", ""), cfg.get("secret_key", "")),
)
if resp.status_code not in (200, 207):
if resp.status_code != 200:
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
partial = (resp.json() or {}).get("partialSuccess") or {}
if partial.get("rejectedSpans"):
log.warning("langfuse 일부 거부: %s", partial)
return False
return True
except Exception as e: # noqa: BLE001 — 관측용이라 절대 본 흐름 안 깨뜨림
log.warning("langfuse 전송 실패: %s: %s", type(e).__name__, e)
return False
def send_later(events: list[dict]) -> None:
def send_later(spans: list[dict]) -> None:
"""지금 흐름 안 막고 백그라운드로. 이벤트 루프 없으면(동기 테스트) 조용히 버림."""
if not enabled() or not events:
if not enabled() or not spans:
return
try:
task = asyncio.get_running_loop().create_task(send(events))
task = asyncio.get_running_loop().create_task(send(spans))
except RuntimeError:
return
_pending.add(task)