fix(observability): Phoenix 는 OTLP protobuf 만 받음(JSON 415) — opentelemetry-proto 로 직렬화, 로컬 Phoenix 실측 통과

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-22 15:43:26 +09:00
co-authored by Claude Fable 5.1
parent e60c5bc273
commit 68b55907f0
4 changed files with 47 additions and 9 deletions
+28 -5
View File
@@ -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
+6 -2
View File
@@ -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` 로 나뉨)
## 운영
+3
View File
@@ -10,3 +10,6 @@ psycopg2-binary==2.9.*
pytest
pytest-django
pytest-asyncio
# LLM 관측(Phoenix 는 OTLP protobuf 만 받음) — common/apps.gateway langfuse.py
opentelemetry-proto
+10 -2
View File
@@ -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])