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:
co-authored by
Claude Fable 5.1
parent
e60c5bc273
commit
68b55907f0
@@ -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
|
||||
|
||||
@@ -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` 로 나뉨)
|
||||
|
||||
## 운영
|
||||
|
||||
@@ -10,3 +10,6 @@ psycopg2-binary==2.9.*
|
||||
pytest
|
||||
pytest-django
|
||||
pytest-asyncio
|
||||
|
||||
# LLM 관측(Phoenix 는 OTLP protobuf 만 받음) — common/apps.gateway langfuse.py
|
||||
opentelemetry-proto
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user