diff --git a/5_django_backend/apps/gateway/langfuse.py b/5_django_backend/apps/gateway/langfuse.py index 1f53166..439cefa 100644 --- a/5_django_backend/apps/gateway/langfuse.py +++ b/5_django_backend/apps/gateway/langfuse.py @@ -3,6 +3,7 @@ 대상은 설정으로 고름(둘 다 켜도 됨): 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). @@ -186,6 +187,24 @@ 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)} +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": [{ @@ -200,17 +219,21 @@ async def send(spans: list[dict]) -> bool: if not spans or not enabled(): return False body = _otlp_body(spans) - targets: list[tuple[str, str, dict, tuple | None]] = [] + # (이름, 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", "")))) + {"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", {}, None)) + targets.append(("phoenix", cfg["phoenix"].rstrip("/") + "/v1/traces", {"Content-Type": "application/x-protobuf"}, None, True)) ok = False - for name, url, headers, auth in targets: + for name, url, headers, auth, pb in targets: try: async with httpx.AsyncClient(timeout=5.0, transport=TRANSPORT) as client: - resp = await client.post(url, json=body, headers=headers, auth=auth) + 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 diff --git a/5_django_backend/deploy/phoenix/README.md b/5_django_backend/deploy/phoenix/README.md index 968d3e2..61e93e3 100644 --- a/5_django_backend/deploy/phoenix/README.md +++ b/5_django_backend/deploy/phoenix/README.md @@ -24,8 +24,12 @@ run.sh 가 하는 것: `PHOENIX_PORT=8915 PHOENIX_WORKING_DIR=/www/phoenix/data` ## 3. 백엔드 연결 -- -12 `code-assistant/5_django_backend/.env`: `PHOENIX_HOST=http://127.0.0.1:8915` → `bash deploy.sh` -- -13 `abap-specgen/web/deploy/bare/.env`: `PHOENIX_HOST=http://10.196.81.34:8915` → `run.sh restart` +Phoenix 는 protobuf 로만 받아서 백엔드 venv 에 `opentelemetry-proto`(+protobuf) 가 있어야 함 — wheel 은 같은 묶음에 들어 있음. + +- -12: `cd /www/abap-ito/code-assistant/5_django_backend && .venv/bin/pip install --no-index --find-links /www/phoenix-wheels opentelemetry-proto` + → `.env` 에 `PHOENIX_HOST=http://127.0.0.1:8915` → `bash deploy.sh` +- -13: wheel 2개만 따로(`otel-proto-wheels.zip`, 400KB) → `cd /www/abap-ito/abap-specgen/web/BE/code && .venv/bin/pip install --no-index --find-links /www/otel-proto-wheels opentelemetry-proto` + → `bare/.env` 에 `PHOENIX_HOST=http://10.196.81.34:8915` → `run.sh restart` - 웹 UI: 고객사 PC 에서 `http://10.196.81.34:8915` (프로젝트 `codeassist` / `abap-specgen` 로 나뉨) ## 운영 diff --git a/5_django_backend/requirements.txt b/5_django_backend/requirements.txt index d9e8715..83f060c 100644 --- a/5_django_backend/requirements.txt +++ b/5_django_backend/requirements.txt @@ -10,3 +10,6 @@ psycopg2-binary==2.9.* pytest pytest-django pytest-asyncio + +# LLM 관측(Phoenix 는 OTLP protobuf 만 받음) — common/apps.gateway langfuse.py +opentelemetry-proto diff --git a/5_django_backend/tests/test_langfuse.py b/5_django_backend/tests/test_langfuse.py index f46130d..809bccd 100644 --- a/5_django_backend/tests/test_langfuse.py +++ b/5_django_backend/tests/test_langfuse.py @@ -18,7 +18,15 @@ 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)}) + if req.headers.get("content-type", "").startswith("application/x-protobuf"): + from google.protobuf.json_format import MessageToDict + from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest + + m = ExportTraceServiceRequest(); m.ParseFromString(req.content) + body = MessageToDict(m) # bytes 필드는 base64 로 나옴 + else: + body = json.loads(req.content) + got.append({"url": str(req.url), "auth": req.headers.get("authorization"), "body": body, "ctype": req.headers.get("content-type", "")}) return httpx.Response(200, json={}) monkeypatch.setattr(langfuse, "TRANSPORT", httpx.MockTransport(handler)) @@ -115,7 +123,7 @@ async def test_phoenix_target_no_auth_and_openinference_attrs(lf_server): tool = langfuse.tool_span("t9", "read", parent=root, input={"filePath": "a.md"}, output="본문") assert await langfuse.send([root, tool]) and len(lf_server) == 1 req = lf_server[0] - assert req["url"] == "http://px.test/v1/traces" and req["auth"] is None + assert req["url"] == "http://px.test/v1/traces" and req["auth"] is None and req["ctype"].startswith("application/x-protobuf") res = {a["key"]: a["value"]["stringValue"] for a in req["body"]["resourceSpans"][0]["resource"]["attributes"]} assert res["openinference.project.name"] == "codeassist" r, tl = _attrs(req["body"]["resourceSpans"][0]["scopeSpans"][0]["spans"][0]), _attrs(req["body"]["resourceSpans"][0]["scopeSpans"][0]["spans"][1])