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