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= LANGFUSE_SECRET_KEY=
# Phoenix(도커 없이 뜨는 관측 서버, deploy/phoenix/) — 주소만. 비우면 안 보냄. 예: http://127.0.0.1:8915 # Phoenix(도커 없이 뜨는 관측 서버, deploy/phoenix/) — 주소만. 비우면 안 보냄. 예: http://127.0.0.1:8915
PHOENIX_HOST= PHOENIX_HOST=
# /phoenix/ 관리자 화면 비번(브라우저 Basic 인증, 아이디는 아무거나). 비우면 잠금 없음
PHOENIX_UI_PASSWORD=
# 1 이면 FabriX 호출 원문(프롬프트 전체·응답)도 별도 fabrix 트레이스로. 기본 0 — 질문 하나 = 행 하나 유지 # 1 이면 FabriX 호출 원문(프롬프트 전체·응답)도 별도 fabrix 트레이스로. 기본 0 — 질문 하나 = 행 하나 유지
LANGFUSE_TRACE_GATEWAY=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"), "phoenix": _env("PHOENIX_HOST"),
"project": _env("TRACE_PROJECT", "codeassist"), "project": _env("TRACE_PROJECT", "codeassist"),
} }
# /phoenix/ 화면 잠금(Basic 인증 비번). 비우면 누구나 봄
PHOENIX_UI_PASSWORD = _env("PHOENIX_UI_PASSWORD")
# 세션 컨텍스트 하드 한도 — usage.limit 로 프론트 게이지에 감 # 세션 컨텍스트 하드 한도 — usage.limit 로 프론트 게이지에 감
CONTEXT_LIMIT_TOKENS = int(_env("CONTEXT_LIMIT_TOKENS", "128000") or "128000") 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.conf import settings
from django.contrib import admin from django.contrib import admin
from django.http import JsonResponse 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.accounts.views import MeView
from apps.gateway import phoenix_proxy
from common.envelope import envelope from common.envelope import envelope
@@ -24,4 +25,5 @@ urlpatterns = [
path("api/v1/admin/", include("apps.stats.urls")), # 관리자 대시보드 집계 path("api/v1/admin/", include("apps.stats.urls")), # 관리자 대시보드 집계
path("api/v1/snippets", include("apps.snippets.urls")), # 공용 스니펫(PostgreSQL). 슬래시 없이 — 프론트가 /snippets 로 침 path("api/v1/snippets", include("apps.snippets.urls")), # 공용 스니펫(PostgreSQL). 슬래시 없이 — 프론트가 /snippets 로 침
path("", include("apps.gateway.urls")), # /api/ito/* — 사내 LLM(FabriX) 중계 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 grep -q '^OPENCODE_VISION_MODEL=' .env || echo 'OPENCODE_VISION_MODEL=gateway/605' >> .env
# 고객사 FabriX 는 Bearer 접두 필수(9/18 실측). 첫 시도부터 맞게 — 날것으로 보내면 401 뒤 재시도에 기댐 # 고객사 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' 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 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 .venv/bin/python manage.py migrate --noinput
[ -f seed/snippets.db ] && .venv/bin/python manage.py import_snippets seed/snippets.db [ -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` `.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` - -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` `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_GRPC_PORT=${PHOENIX_GRPC_PORT:-4317}
export PHOENIX_WORKING_DIR=$ROOT/data export PHOENIX_WORKING_DIR=$ROOT/data
export PHOENIX_TELEMETRY_ENABLED=false export PHOENIX_TELEMETRY_ENABLED=false
export PHOENIX_HOST_ROOT_PATH=/phoenix # 백엔드 8914 뒤 /phoenix/ 로 노출되니 링크가 그 경로로 나오게
export PHOENIX_ALLOW_EXTERNAL_RESOURCES=false # 폐쇄망 — 구글 폰트 같은 거 안 부름 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_URL=postgresql://user:pw@10.196.81.34:5432/dbname # 고객사 PG 쓰려면
# export PHOENIX_SQL_DATABASE_SCHEMA=phoenix # 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 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 & cd "$ROOT" && nohup .venv/bin/python -m phoenix.server.main serve > "$ROOT/phoenix.log" 2>&1 &
sleep 6 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