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
+2
View File
@@ -69,5 +69,7 @@ LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
# Phoenix(도커 없이 뜨는 관측 서버, deploy/phoenix/) — 주소만. 비우면 안 보냄. 예: http://127.0.0.1:8915
PHOENIX_HOST=
# /phoenix/ 관리자 화면 비번(브라우저 Basic 인증, 아이디는 아무거나). 비우면 잠금 없음
PHOENIX_UI_PASSWORD=
# 1 이면 FabriX 호출 원문(프롬프트 전체·응답)도 별도 fabrix 트레이스로. 기본 0 — 질문 하나 = 행 하나 유지
LANGFUSE_TRACE_GATEWAY=0
@@ -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
+2
View File
@@ -99,6 +99,8 @@ LANGFUSE = {
"phoenix": _env("PHOENIX_HOST"),
"project": _env("TRACE_PROJECT", "codeassist"),
}
# /phoenix/ 화면 잠금(Basic 인증 비번). 비우면 누구나 봄
PHOENIX_UI_PASSWORD = _env("PHOENIX_UI_PASSWORD")
# 세션 컨텍스트 하드 한도 — usage.limit 로 프론트 게이지에 감
CONTEXT_LIMIT_TOKENS = int(_env("CONTEXT_LIMIT_TOKENS", "128000") or "128000")
+3 -1
View File
@@ -3,9 +3,10 @@
from django.conf import settings
from django.contrib import admin
from django.http import JsonResponse
from django.urls import include, path
from django.urls import include, path, re_path
from apps.accounts.views import MeView
from apps.gateway import phoenix_proxy
from common.envelope import envelope
@@ -24,4 +25,5 @@ urlpatterns = [
path("api/v1/admin/", include("apps.stats.urls")), # 관리자 대시보드 집계
path("api/v1/snippets", include("apps.snippets.urls")), # 공용 스니펫(PostgreSQL). 슬래시 없이 — 프론트가 /snippets 로 침
path("", include("apps.gateway.urls")), # /api/ito/* — 사내 LLM(FabriX) 중계
re_path(r"^phoenix(?:/(?P<rest>.*))?$", phoenix_proxy.proxy), # 관측 화면 — 8914 뒤에 붙임(바깥 포트가 이것뿐)
]
+1 -1
View File
@@ -19,7 +19,7 @@ grep -q '^BACKEND_PORT=' .env || echo 'BACKEND_PORT=8080' >> .env
grep -q '^OPENCODE_VISION_MODEL=' .env || echo 'OPENCODE_VISION_MODEL=gateway/605' >> .env
# 고객사 FabriX 는 Bearer 접두 필수(9/18 실측). 첫 시도부터 맞게 — 날것으로 보내면 401 뒤 재시도에 기댐
grep -q '^AAF_FABRIX_TOKEN_PREFIX=' .env || echo 'AAF_FABRIX_TOKEN_PREFIX=bearer' >> .env # OpenCode 가 붙을 Django 포트. 없으면 8001 로 렌더돼 'Unable to connect'
for k in LANGFUSE_HOST LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY PHOENIX_HOST; do grep -q "^$k=" .env || echo "$k=" >> .env; done # 비우면 관측 안 함
for k in LANGFUSE_HOST LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY PHOENIX_HOST PHOENIX_UI_PASSWORD; do grep -q "^$k=" .env || echo "$k=" >> .env; done # 비우면 관측 안 함
sed -i 's/^AAF_FABRIX_MODEL_ID=.*/AAF_FABRIX_MODEL_ID=581/; s/^AAF_FABRIX_MODELS=.*/AAF_FABRIX_MODELS=581:GaussO Think,339:GaussO Flash,605:Gemma4/' .env
.venv/bin/python manage.py migrate --noinput
[ -f seed/snippets.db ] && .venv/bin/python manage.py import_snippets seed/snippets.db
+2 -1
View File
@@ -30,7 +30,8 @@ Phoenix 는 protobuf 로만 받아서 백엔드 venv 에 `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:8914/phoenix/`** — 백엔드(8914)가 중계(`apps/gateway/phoenix_proxy.py`). 바깥에서 -12 로 오는 포트가 8914 뿐이라(8913/8915 는 다른 서비스) 이렇게 감.
브라우저가 비번 물으면 `.env``PHOENIX_UI_PASSWORD`(아이디 아무거나). 프로젝트 `codeassist` / `abap-specgen` 로 나뉨
## 운영
+2 -1
View File
@@ -7,6 +7,7 @@ export PHOENIX_HOST=0.0.0.0
export PHOENIX_GRPC_PORT=${PHOENIX_GRPC_PORT:-4317}
export PHOENIX_WORKING_DIR=$ROOT/data
export PHOENIX_TELEMETRY_ENABLED=false
export PHOENIX_HOST_ROOT_PATH=/phoenix # 백엔드 8914 뒤 /phoenix/ 로 노출되니 링크가 그 경로로 나오게
export PHOENIX_ALLOW_EXTERNAL_RESOURCES=false # 폐쇄망 — 구글 폰트 같은 거 안 부름
# export PHOENIX_SQL_DATABASE_URL=postgresql://user:pw@10.196.81.34:5432/dbname # 고객사 PG 쓰려면
# export PHOENIX_SQL_DATABASE_SCHEMA=phoenix
@@ -14,4 +15,4 @@ mkdir -p "$PHOENIX_WORKING_DIR"
pkill -f "phoenix serve" 2>/dev/null || true; sleep 1
cd "$ROOT" && nohup .venv/bin/python -m phoenix.server.main serve > "$ROOT/phoenix.log" 2>&1 &
sleep 6
echo "--- health"; curl -s "http://127.0.0.1:$PHOENIX_PORT/healthz" || tail -5 "$ROOT/phoenix.log"; echo
echo "--- health"; curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:$PHOENIX_PORT/healthz" || tail -5 "$ROOT/phoenix.log"
@@ -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