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.* 속성)
|
LANGFUSE_HOST → {host}/api/public/otel/v1/traces (Basic 인증, langfuse.* 속성)
|
||||||
PHOENIX_HOST → {host}/v1/traces (인증 없음, OpenInference 속성: openinference.span.kind, input.value …)
|
PHOENIX_HOST → {host}/v1/traces (인증 없음, OpenInference 속성: openinference.span.kind, input.value …)
|
||||||
|
Phoenix 는 JSON 을 안 받아서(415, 2026-09-22 실측) protobuf 로 바꿔 보냄 — opentelemetry-proto 필요
|
||||||
속성은 두 벌을 같은 span 에 같이 실음 — 각자 자기 것만 읽고 나머진 metadata 로 떨어짐.
|
속성은 두 벌을 같은 span 에 같이 실음 — 각자 자기 것만 읽고 나머진 metadata 로 떨어짐.
|
||||||
|
|
||||||
v4 는 옛 /api/public/ingestion 이 막혀서(score 만) OTel 엔드포인트로 감. 트레이스 하나 = 루트 span(trace 속성) + 자식 span(generation).
|
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)}
|
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:
|
def _otlp_body(spans: list[dict]) -> dict:
|
||||||
project = settings.LANGFUSE.get("project") or "codeassist"
|
project = settings.LANGFUSE.get("project") or "codeassist"
|
||||||
return {"resourceSpans": [{
|
return {"resourceSpans": [{
|
||||||
@@ -200,17 +219,21 @@ async def send(spans: list[dict]) -> bool:
|
|||||||
if not spans or not enabled():
|
if not spans or not enabled():
|
||||||
return False
|
return False
|
||||||
body = _otlp_body(spans)
|
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"):
|
if cfg.get("host"):
|
||||||
targets.append(("langfuse", cfg["host"].rstrip("/") + "/api/public/otel/v1/traces",
|
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"):
|
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
|
ok = False
|
||||||
for name, url, headers, auth in targets:
|
for name, url, headers, auth, pb in targets:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5.0, transport=TRANSPORT) as client:
|
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:
|
if resp.status_code != 200:
|
||||||
log.warning("%s ← %s %s", name, resp.status_code, resp.text[:200])
|
log.warning("%s ← %s %s", name, resp.status_code, resp.text[:200])
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ run.sh 가 하는 것: `PHOENIX_PORT=8915 PHOENIX_WORKING_DIR=/www/phoenix/data`
|
|||||||
|
|
||||||
## 3. 백엔드 연결
|
## 3. 백엔드 연결
|
||||||
|
|
||||||
- -12 `code-assistant/5_django_backend/.env`: `PHOENIX_HOST=http://127.0.0.1:8915` → `bash deploy.sh`
|
Phoenix 는 protobuf 로만 받아서 백엔드 venv 에 `opentelemetry-proto`(+protobuf) 가 있어야 함 — wheel 은 같은 묶음에 들어 있음.
|
||||||
- -13 `abap-specgen/web/deploy/bare/.env`: `PHOENIX_HOST=http://10.196.81.34:8915` → `run.sh restart`
|
|
||||||
|
- -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` 로 나뉨)
|
- 웹 UI: 고객사 PC 에서 `http://10.196.81.34:8915` (프로젝트 `codeassist` / `abap-specgen` 로 나뉨)
|
||||||
|
|
||||||
## 운영
|
## 운영
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ psycopg2-binary==2.9.*
|
|||||||
pytest
|
pytest
|
||||||
pytest-django
|
pytest-django
|
||||||
pytest-asyncio
|
pytest-asyncio
|
||||||
|
|
||||||
|
# LLM 관측(Phoenix 는 OTLP protobuf 만 받음) — common/apps.gateway langfuse.py
|
||||||
|
opentelemetry-proto
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ def lf_server(monkeypatch):
|
|||||||
got: list[dict] = []
|
got: list[dict] = []
|
||||||
|
|
||||||
def handler(req: httpx.Request) -> httpx.Response:
|
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={})
|
return httpx.Response(200, json={})
|
||||||
|
|
||||||
monkeypatch.setattr(langfuse, "TRANSPORT", httpx.MockTransport(handler))
|
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="본문")
|
tool = langfuse.tool_span("t9", "read", parent=root, input={"filePath": "a.md"}, output="본문")
|
||||||
assert await langfuse.send([root, tool]) and len(lf_server) == 1
|
assert await langfuse.send([root, tool]) and len(lf_server) == 1
|
||||||
req = lf_server[0]
|
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"]}
|
res = {a["key"]: a["value"]["stringValue"] for a in req["body"]["resourceSpans"][0]["resource"]["attributes"]}
|
||||||
assert res["openinference.project.name"] == "codeassist"
|
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])
|
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