"""LLM 관측 전송 — Langfuse 또는 Phoenix 로, SDK 없이 OTLP/HTTP JSON 한 방 (docs-lib/langfuse.md, docs-lib/phoenix.md). 대상은 설정으로 고름(둘 다 켜도 됨): LANGFUSE_HOST → {host}/api/public/otel/v1/traces (Basic 인증, langfuse.* 속성) PHOENIX_HOST → {host}/v1/traces (인증 없음, OpenInference 속성: openinference.span.kind, input.value …) Phoenix 는 JSON 을 안 받아서(415, 2026-09-22 실측) protobuf 로 바꿔 보냄 — opentelemetry-proto 필요 속성은 두 벌을 같은 span 에 같이 실음 — 각자 자기 것만 읽고 나머진 metadata 로 떨어짐. v4 는 옛 /api/public/ingestion 이 막혀서(score 만) OTel 엔드포인트로 감. 트레이스 하나 = 루트 span(trace 속성) + 자식 span(generation). 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 hashlib import json import logging import time 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") or settings.LANGFUSE.get("phoenix")) def _oi(kind: str, *, input=None, output=None, model: str = "", usage: dict | None = None, userId: str = "", sessionId: str = "", tool: str = "", metadata: dict | None = None) -> dict: """OpenInference(Phoenix) 속성 한 벌. 값이 없으면 _attr 이 걸러줌.""" js = lambda v: v if v is None or isinstance(v, str) else json.dumps(v, ensure_ascii=False) # noqa: E731 return { "openinference.span.kind": kind, "input.value": js(input), "input.mime_type": None if input is None or isinstance(input, str) else "application/json", "output.value": js(output), "output.mime_type": None if output is None or isinstance(output, str) else "application/json", "llm.model_name": model, "llm.token_count.prompt": (usage or {}).get("input"), "llm.token_count.completion": (usage or {}).get("output"), "llm.token_count.total": (usage or {}).get("total"), "session.id": sessionId, "user.id": userId, "tool.name": tool, "metadata": js(metadata) if metadata else None, } def now_iso() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def _trace_hex(trace_id: str) -> str: """아무 문자열 → OTel traceId(16바이트 hex). 같은 문자열이면 같은 trace 로 묶임.""" return hashlib.sha256(trace_id.encode()).hexdigest()[:32] 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 _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, usage: dict | None = None, model: str = "", error: str | None = None) -> dict: """루트 span. trace 속성(이름·사용자·세션·태그)은 여기 실림. usage 를 주면 루트 자체가 generation — 목록 한 줄 = 질문/답/토큰, 밑에 도구 호출만 트리로.""" attrs = { "langfuse.trace.name": name, "langfuse.observation.type": "generation" if usage else "span", "langfuse.observation.model.name": model, "langfuse.observation.usage_details": usage, "langfuse.observation.level": "ERROR" if error else "DEFAULT", "langfuse.observation.status_message": error or "", "langfuse.user.id": userId, "langfuse.session.id": sessionId, "langfuse.trace.input": input, "langfuse.trace.output": output, "langfuse.observation.input": input, # 루트 span 본문에도 — 트레이스 화면이 루트 span 의 입출력을 보여줌(실측) "langfuse.observation.output": output, "langfuse.trace.tags": tags, **{f"langfuse.trace.metadata.{k}": v for k, v in (metadata or {}).items()}, **_oi("LLM" if usage else "CHAIN", input=input, output=output, model=model, usage=usage, userId=userId, sessionId=sessionId, metadata={**(metadata or {}), "tags": tags} if (metadata or tags) else None), } return _span(trace_id, name, attrs, start=start, end=end, parent=None, error=error) def tool_span(trace_id: str, name: str, *, parent: dict, input=None, output=None, title: str = "", start: str | None = None, end: str | None = None, error: str | None = None) -> dict: """도구 호출 하나(SAP 조회·위키 read·grep…). 트리에서 루트 밑에 순서대로 보임.""" inherited = {a["key"]: a["value"].get("stringValue") for a in parent.get("attributes", []) if a["key"] in ("langfuse.user.id", "langfuse.session.id", "langfuse.trace.name", "langfuse.trace.tags")} attrs = { **inherited, "langfuse.observation.type": "tool", "langfuse.observation.input": input, "langfuse.observation.output": output, "langfuse.observation.level": "ERROR" if error else "DEFAULT", "langfuse.observation.status_message": error or "", "langfuse.observation.metadata.title": title, **_oi("TOOL", input=input, output=output, tool=name, userId=inherited.get("langfuse.user.id", ""), sessionId=inherited.get("langfuse.session.id", ""), metadata={"title": title} if title else None), } return _span(trace_id, name, attrs, start=start, end=end, parent=parent["spanId"], error=error) def ms_iso(ms: int | float | None) -> str | None: """OpenCode 의 epoch ms → ISO. 없으면 None(=지금).""" return datetime.fromtimestamp(ms / 1000, timezone.utc).isoformat() if ms else 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()}, **_oi("LLM", input=input, output=output, model=model, usage=usage, userId=inherited.get("langfuse.user.id", ""), sessionId=inherited.get("langfuse.session.id", ""), metadata=metadata), } 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: 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)} def _to_protobuf(body: dict) -> bytes: """OTLP JSON → protobuf 바이트. traceId/spanId 는 JSON 이 hex, proto-JSON 은 base64 라 바꿔 넣음.""" import base64 import copy from google.protobuf.json_format import ParseDict from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest b = copy.deepcopy(body) for rs in b["resourceSpans"]: for ss in rs["scopeSpans"]: for sp in ss["spans"]: for k in ("traceId", "spanId", "parentSpanId"): if k in sp: sp[k] = base64.b64encode(bytes.fromhex(sp[k])).decode() return ParseDict(b, ExportTraceServiceRequest()).SerializeToString() def _otlp_body(spans: list[dict]) -> dict: project = settings.LANGFUSE.get("project") or "codeassist" return {"resourceSpans": [{ "resource": {"attributes": [_attr("service.name", project), _attr("openinference.project.name", project)]}, "scopeSpans": [{"scope": {"name": project}, "spans": spans}], }]} async def send(spans: list[dict]) -> bool: """설정된 대상 전부에 전송. 하나라도 성공하면 True. 실패는 로그만.""" cfg = settings.LANGFUSE if not spans or not enabled(): return False body = _otlp_body(spans) # (이름, url, headers, auth, protobuf 여부) targets: list[tuple[str, str, dict, tuple | None, bool]] = [] if cfg.get("host"): targets.append(("langfuse", cfg["host"].rstrip("/") + "/api/public/otel/v1/traces", {"x-langfuse-ingestion-version": "4"}, (cfg.get("public_key", ""), cfg.get("secret_key", "")), False)) if cfg.get("phoenix"): targets.append(("phoenix", cfg["phoenix"].rstrip("/") + "/v1/traces", {"Content-Type": "application/x-protobuf"}, None, True)) ok = False for name, url, headers, auth, pb in targets: try: async with httpx.AsyncClient(timeout=5.0, transport=TRANSPORT) as client: if pb: resp = await client.post(url, content=_to_protobuf(body), headers=headers, auth=auth) else: resp = await client.post(url, json=body, headers=headers, auth=auth) if resp.status_code != 200: log.warning("%s ← %s %s", name, resp.status_code, resp.text[:200]) continue partial = {} try: partial = (resp.json() or {}).get("partialSuccess") or {} except ValueError: pass if partial.get("rejectedSpans"): log.warning("%s 일부 거부: %s", name, partial) continue ok = True except Exception as e: # noqa: BLE001 — 관측용이라 절대 본 흐름 안 깨뜨림 log.warning("%s 전송 실패: %s: %s", name, type(e).__name__, e) return ok def send_later(spans: list[dict]) -> None: """지금 흐름 안 막고 백그라운드로. 이벤트 루프 없으면(동기 테스트) 조용히 버림.""" if not enabled() or not spans: return try: task = asyncio.get_running_loop().create_task(send(spans)) except RuntimeError: return _pending.add(task) task.add_done_callback(_pending.discard)