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:
co-authored by
Claude Fable 5.1
parent
8268947fc7
commit
ac7a459dfc
@@ -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
|
||||
Reference in New Issue
Block a user