From ac7a459dfcf0d49470a09d80628fcb40b61c5a79 Mon Sep 17 00:00:00 2001 From: lee-hyeon-cheol Date: Tue, 22 Sep 2026 16:34:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(observability):=20/phoenix/=20=EC=A4=91?= =?UTF-8?q?=EA=B3=84=20=E2=80=94=20=EB=B0=94=EA=B9=A5=20=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=208914=20=EB=BF=90=EC=9D=B4=EB=9D=BC=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EB=92=A4=EC=97=90=20Phoenix=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=B6=99=EC=9E=84(Basic=20=EC=9E=A0=EA=B8=88,=20?= =?UTF-8?q?=EC=A0=91=EB=91=90=EC=96=B4=20=EB=96=BC=EA=B3=A0=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC).=20=EB=A1=9C=EC=BB=AC=20e2e(HTML=C2=B7=EC=9E=90?= =?UTF-8?q?=EC=82=B0=C2=B7GraphQL=C2=B7REST)=20=ED=86=B5=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- 5_django_backend/.env.example | 2 + .../apps/gateway/phoenix_proxy.py | 74 +++++++++++++++++++ 5_django_backend/config/settings.py | 2 + 5_django_backend/config/urls.py | 4 +- 5_django_backend/deploy.sh | 2 +- 5_django_backend/deploy/phoenix/README.md | 3 +- 5_django_backend/deploy/phoenix/run.sh | 3 +- 5_django_backend/tests/test_phoenix_proxy.py | 60 +++++++++++++++ 8 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 5_django_backend/apps/gateway/phoenix_proxy.py create mode 100644 5_django_backend/tests/test_phoenix_proxy.py diff --git a/5_django_backend/.env.example b/5_django_backend/.env.example index 951136b..4fd5c9e 100644 --- a/5_django_backend/.env.example +++ b/5_django_backend/.env.example @@ -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 diff --git a/5_django_backend/apps/gateway/phoenix_proxy.py b/5_django_backend/apps/gateway/phoenix_proxy.py new file mode 100644 index 0000000..85ecd62 --- /dev/null +++ b/5_django_backend/apps/gateway/phoenix_proxy.py @@ -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/ → PHOENIX_HOST/ (접두어 떼고). 쿼리는 그대로. + 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 diff --git a/5_django_backend/config/settings.py b/5_django_backend/config/settings.py index 3123fed..19de1c1 100644 --- a/5_django_backend/config/settings.py +++ b/5_django_backend/config/settings.py @@ -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") diff --git a/5_django_backend/config/urls.py b/5_django_backend/config/urls.py index b738aa1..9c0e672 100644 --- a/5_django_backend/config/urls.py +++ b/5_django_backend/config/urls.py @@ -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.*))?$", phoenix_proxy.proxy), # 관측 화면 — 8914 뒤에 붙임(바깥 포트가 이것뿐) ] diff --git a/5_django_backend/deploy.sh b/5_django_backend/deploy.sh index 8148001..7f9634c 100644 --- a/5_django_backend/deploy.sh +++ b/5_django_backend/deploy.sh @@ -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 diff --git a/5_django_backend/deploy/phoenix/README.md b/5_django_backend/deploy/phoenix/README.md index 61e93e3..3d6a7c0 100644 --- a/5_django_backend/deploy/phoenix/README.md +++ b/5_django_backend/deploy/phoenix/README.md @@ -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` 로 나뉨 ## 운영 diff --git a/5_django_backend/deploy/phoenix/run.sh b/5_django_backend/deploy/phoenix/run.sh index 90ad340..1654769 100644 --- a/5_django_backend/deploy/phoenix/run.sh +++ b/5_django_backend/deploy/phoenix/run.sh @@ -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" diff --git a/5_django_backend/tests/test_phoenix_proxy.py b/5_django_backend/tests/test_phoenix_proxy.py new file mode 100644 index 0000000..873ef37 --- /dev/null +++ b/5_django_backend/tests/test_phoenix_proxy.py @@ -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"ok", 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"ok" 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