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,60 @@
"""/phoenix/… 중계 — 경로·쿼리 전달, Basic 잠금, 상류 죽으면 502."""
import base64
import json
import httpx
from django.test import AsyncClient, override_settings
from apps.gateway import phoenix_proxy
PX = {"host": "", "public_key": "", "secret_key": "", "gateway": False, "phoenix": "http://127.0.0.1:8915", "project": "codeassist"}
def _upstream(monkeypatch, handler):
calls = []
def h(req):
calls.append(req)
r = handler(req) # content= 로 만든 응답은 stream 으로 못 읽어서(StreamConsumed) 바꿔 끼움
return httpx.Response(r.status_code, stream=httpx.ByteStream(r.content), headers=r.headers)
monkeypatch.setattr(phoenix_proxy, "TRANSPORT", httpx.MockTransport(h))
return calls
@override_settings(LANGFUSE=PX, PHOENIX_UI_PASSWORD="")
async def test_forwards_path_query_and_body(monkeypatch):
calls = _upstream(monkeypatch, lambda r: httpx.Response(200, content=b"<html>ok</html>", headers={"content-type": "text/html"}))
resp = await AsyncClient().post("/phoenix/graphql?x=1", data=json.dumps({"q": 1}), content_type="application/json")
body = b"".join([c async for c in resp.streaming_content])
assert resp.status_code == 200 and body == b"<html>ok</html>" and resp["Content-Type"] == "text/html"
assert str(calls[0].url) == "http://127.0.0.1:8915/graphql?x=1" and calls[0].method == "POST" and json.loads(calls[0].content) == {"q": 1}
resp = await AsyncClient().get("/phoenix")
b"".join([c async for c in resp.streaming_content])
assert str(calls[1].url) == "http://127.0.0.1:8915/"
@override_settings(LANGFUSE=PX, PHOENIX_UI_PASSWORD="s3cret")
async def test_basic_auth_gate(monkeypatch):
calls = _upstream(monkeypatch, lambda r: httpx.Response(200, content=b"ok"))
resp = await AsyncClient().get("/phoenix/")
assert resp.status_code == 401 and resp["WWW-Authenticate"].startswith("Basic") and calls == []
ok = "Basic " + base64.b64encode(b"admin:s3cret").decode()
resp = await AsyncClient().get("/phoenix/", headers={"Authorization": ok})
b"".join([c async for c in resp.streaming_content])
assert resp.status_code == 200 and "authorization" not in {k.lower() for k in calls[0].headers}
@override_settings(LANGFUSE=PX, PHOENIX_UI_PASSWORD="")
async def test_upstream_down_is_502(monkeypatch):
def boom(r):
raise httpx.ConnectError("down")
_upstream(monkeypatch, boom)
assert (await AsyncClient().get("/phoenix/")).status_code == 502
@override_settings(LANGFUSE={**PX, "phoenix": ""}, PHOENIX_UI_PASSWORD="")
async def test_not_configured_is_503():
assert (await AsyncClient().get("/phoenix/")).status_code == 503