feat(observability): /phoenix/ 중계 — 바깥 포트가 8914 뿐이라 백엔드 뒤에 Phoenix 화면 붙임(Basic 잠금, 접두어 떼고 전달). 로컬 e2e(HTML·자산·GraphQL·REST) 통과

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-22 16:34:38 +09:00
co-authored by Claude Fable 5.1
parent 8268947fc7
commit ac7a459dfc
8 changed files with 146 additions and 4 deletions
@@ -0,0 +1,74 @@
"""`/phoenix/…` → Phoenix(127.0.0.1:8915) 중계 — 바깥에서 -12 로 오는 포트가 8914(우리 백엔드) 하나뿐이라 그 뒤에 붙임.
- Phoenix 는 PHOENIX_HOST_ROOT_PATH=/phoenix 로 떠 있음 — 이건 HTML 링크에만 /phoenix 를 붙이는 옵션이고
실제 경로는 접두어 없이 받음(실측: /phoenix/assets/… 는 SPA 폴백 HTML, /assets/… 가 진짜). 그래서 여기서 /phoenix 를 떼고 넘김.
- 관리자 화면이라 HTTP Basic 으로 잠금: PHOENIX_UI_PASSWORD (아이디 아무거나). 비우면 잠금 없음 — 사내망이라도 채우는 걸 권장.
- 웹소켓은 안 넘김(Django ASGI 가 안 받음) — 트레이스 화면은 HTTP 만으로 동작. 플레이그라운드 스트리밍 같은 건 안 됨.
"""
from __future__ import annotations
import base64
import httpx
from django.conf import settings
from django.http import HttpRequest, HttpResponse, StreamingHttpResponse
from django.views.decorators.csrf import csrf_exempt
TRANSPORT: httpx.AsyncBaseTransport | None = None # 테스트용
_HOP = {"connection", "keep-alive", "transfer-encoding", "te", "trailer", "upgrade", "proxy-authorization", "host", "content-length"}
def _unauthorized() -> HttpResponse:
resp = HttpResponse("Phoenix 관리자 비밀번호 필요", status=401)
resp["WWW-Authenticate"] = 'Basic realm="phoenix"'
return resp
def _password_ok(request: HttpRequest) -> bool:
want = settings.PHOENIX_UI_PASSWORD
if not want:
return True
auth = request.headers.get("Authorization", "")
if not auth.startswith("Basic "):
return False
try:
_, _, pw = base64.b64decode(auth[6:]).decode("utf-8", "replace").partition(":")
except Exception: # noqa: BLE001
return False
return pw == want
@csrf_exempt
async def proxy(request: HttpRequest, rest: str = "") -> HttpResponse:
base = (settings.LANGFUSE.get("phoenix") or "").rstrip("/")
if not base:
return HttpResponse("PHOENIX_HOST 가 비어 있음", status=503)
if not _password_ok(request):
return _unauthorized()
# /phoenix/<rest> → PHOENIX_HOST/<rest> (접두어 떼고). 쿼리는 그대로.
url = base + "/" + rest.lstrip("/")
if request.META.get("QUERY_STRING"):
url += "?" + request.META["QUERY_STRING"]
headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP and k.lower() != "authorization"}
client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=5.0), transport=TRANSPORT)
try:
req = client.build_request(request.method, url, headers=headers, content=request.body)
up = await client.send(req, stream=True)
except httpx.HTTPError as e:
await client.aclose()
return HttpResponse(f"Phoenix 연결 실패: {type(e).__name__}", status=502)
async def body():
try:
async for chunk in up.aiter_raw():
yield chunk
finally:
await up.aclose()
await client.aclose()
resp = StreamingHttpResponse(body(), status=up.status_code, content_type=up.headers.get("content-type", "application/octet-stream"))
for k, v in up.headers.items():
if k.lower() not in _HOP and k.lower() != "content-type":
resp[k] = v
return resp