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
@@ -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
|
||||||
@@ -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,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 뒤에 붙임(바깥 포트가 이것뿐)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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` 로 나뉨
|
||||||
|
|
||||||
## 운영
|
## 운영
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user