From e60c5bc2737a996135123a0c4381865b1cfab1e4 Mon Sep 17 00:00:00 2001 From: lee-hyeon-cheol Date: Tue, 22 Sep 2026 15:39:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(observability):=20Phoenix=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=EC=B6=94=EA=B0=80=20=E2=80=94=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20span=20=EC=97=90=20OpenInference=20=EC=86=8D=EC=84=B1=20?= =?UTF-8?q?=EB=8F=99=EB=B4=89,=20PHOENIX=5FHOST=20=EB=A1=9C=20/v1/traces.?= =?UTF-8?q?=20-12=20=EB=8A=94=20=EB=8F=84=EC=BB=A4=20=EB=B6=88=EA=B0=80?= =?UTF-8?q?=EB=9D=BC=20pip=20=EB=A1=9C=20=EB=9C=A8=EB=8A=94=20Phoenix=20?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=90(deploy/phoenix)?= 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 + 5_django_backend/apps/gateway/langfuse.py | 92 +++++++++++++++++------ 5_django_backend/config/settings.py | 3 + 5_django_backend/deploy.sh | 2 +- 5_django_backend/deploy/phoenix/README.md | 35 +++++++++ 5_django_backend/deploy/phoenix/run.sh | 17 +++++ 5_django_backend/docs-lib/phoenix.md | 21 ++++++ 5_django_backend/tests/test_langfuse.py | 16 ++++ 8 files changed, 162 insertions(+), 26 deletions(-) create mode 100644 5_django_backend/deploy/phoenix/README.md create mode 100644 5_django_backend/deploy/phoenix/run.sh create mode 100644 5_django_backend/docs-lib/phoenix.md diff --git a/5_django_backend/.env.example b/5_django_backend/.env.example index 889345b..951136b 100644 --- a/5_django_backend/.env.example +++ b/5_django_backend/.env.example @@ -67,5 +67,7 @@ ABAP_INDEX_URL=http://127.0.0.1:8100 LANGFUSE_HOST= LANGFUSE_PUBLIC_KEY= LANGFUSE_SECRET_KEY= +# Phoenix(도커 없이 뜨는 관측 서버, deploy/phoenix/) — 주소만. 비우면 안 보냄. 예: http://127.0.0.1:8915 +PHOENIX_HOST= # 1 이면 FabriX 호출 원문(프롬프트 전체·응답)도 별도 fabrix 트레이스로. 기본 0 — 질문 하나 = 행 하나 유지 LANGFUSE_TRACE_GATEWAY=0 diff --git a/5_django_backend/apps/gateway/langfuse.py b/5_django_backend/apps/gateway/langfuse.py index 03531b8..1f53166 100644 --- a/5_django_backend/apps/gateway/langfuse.py +++ b/5_django_backend/apps/gateway/langfuse.py @@ -1,4 +1,9 @@ -"""Langfuse 로 trace/generation 쏘기 — SDK 없이 OTLP/HTTP JSON 한 방 (docs-lib/langfuse.md). +"""LLM 관측 전송 — Langfuse 또는 Phoenix 로, SDK 없이 OTLP/HTTP JSON 한 방 (docs-lib/langfuse.md, docs-lib/phoenix.md). + +대상은 설정으로 고름(둘 다 켜도 됨): + LANGFUSE_HOST → {host}/api/public/otel/v1/traces (Basic 인증, langfuse.* 속성) + PHOENIX_HOST → {host}/v1/traces (인증 없음, OpenInference 속성: openinference.span.kind, input.value …) +속성은 두 벌을 같은 span 에 같이 실음 — 각자 자기 것만 읽고 나머진 metadata 로 떨어짐. v4 는 옛 /api/public/ingestion 이 막혀서(score 만) OTel 엔드포인트로 감. 트레이스 하나 = 루트 span(trace 속성) + 자식 span(generation). LANGFUSE_HOST 가 비어 있으면 전부 no-op. 보내는 건 fire-and-forget: 실패해도 채팅엔 영향 0, 로그만. @@ -28,7 +33,28 @@ _pending: set[asyncio.Task] = set() # GC 에 안 먹히게 잡아둠 def enabled() -> bool: - return bool(settings.LANGFUSE.get("host")) + return bool(settings.LANGFUSE.get("host") or settings.LANGFUSE.get("phoenix")) + + +def _oi(kind: str, *, input=None, output=None, model: str = "", usage: dict | None = None, + userId: str = "", sessionId: str = "", tool: str = "", metadata: dict | None = None) -> dict: + """OpenInference(Phoenix) 속성 한 벌. 값이 없으면 _attr 이 걸러줌.""" + js = lambda v: v if v is None or isinstance(v, str) else json.dumps(v, ensure_ascii=False) # noqa: E731 + return { + "openinference.span.kind": kind, + "input.value": js(input), + "input.mime_type": None if input is None or isinstance(input, str) else "application/json", + "output.value": js(output), + "output.mime_type": None if output is None or isinstance(output, str) else "application/json", + "llm.model_name": model, + "llm.token_count.prompt": (usage or {}).get("input"), + "llm.token_count.completion": (usage or {}).get("output"), + "llm.token_count.total": (usage or {}).get("total"), + "session.id": sessionId, + "user.id": userId, + "tool.name": tool, + "metadata": js(metadata) if metadata else None, + } def now_iso() -> str: @@ -97,6 +123,8 @@ def trace(trace_id: str, name: str, *, userId: str = "", sessionId: str = "", in "langfuse.observation.output": output, "langfuse.trace.tags": tags, **{f"langfuse.trace.metadata.{k}": v for k, v in (metadata or {}).items()}, + **_oi("LLM" if usage else "CHAIN", input=input, output=output, model=model, usage=usage, + userId=userId, sessionId=sessionId, metadata={**(metadata or {}), "tags": tags} if (metadata or tags) else None), } return _span(trace_id, name, attrs, start=start, end=end, parent=None, error=error) @@ -114,6 +142,9 @@ def tool_span(trace_id: str, name: str, *, parent: dict, input=None, output=None "langfuse.observation.level": "ERROR" if error else "DEFAULT", "langfuse.observation.status_message": error or "", "langfuse.observation.metadata.title": title, + **_oi("TOOL", input=input, output=output, tool=name, + userId=inherited.get("langfuse.user.id", ""), sessionId=inherited.get("langfuse.session.id", ""), + metadata={"title": title} if title else None), } return _span(trace_id, name, attrs, start=start, end=end, parent=parent["spanId"], error=error) @@ -142,6 +173,8 @@ def generation(trace_id: str, name: str, *, model: str = "", input=None, output= "langfuse.observation.level": level, "langfuse.observation.status_message": statusMessage, **{f"langfuse.observation.metadata.{k}": v for k, v in (metadata or {}).items()}, + **_oi("LLM", input=input, output=output, model=model, usage=usage, + userId=inherited.get("langfuse.user.id", ""), sessionId=inherited.get("langfuse.session.id", ""), metadata=metadata), } return _span(trace_id, name, attrs, start=startTime, end=endTime, parent=parent["spanId"] if parent else None, error=statusMessage if level == "ERROR" else None) @@ -154,36 +187,45 @@ def usage_of(inp: int | None, out: int | None) -> dict | None: def _otlp_body(spans: list[dict]) -> dict: + project = settings.LANGFUSE.get("project") or "codeassist" return {"resourceSpans": [{ - "resource": {"attributes": [_attr("service.name", "codeassist-backend")]}, - "scopeSpans": [{"scope": {"name": "codeassist"}, "spans": spans}], + "resource": {"attributes": [_attr("service.name", project), _attr("openinference.project.name", project)]}, + "scopeSpans": [{"scope": {"name": project}, "spans": spans}], }]} async def send(spans: list[dict]) -> bool: - """span 묶음 하나 전송. OTLP 는 200 + 빈 JSON 이면 성공, partialSuccess 있으면 일부 실패.""" + """설정된 대상 전부에 전송. 하나라도 성공하면 True. 실패는 로그만.""" cfg = settings.LANGFUSE - if not cfg.get("host") or not spans: - return False - try: - async with httpx.AsyncClient(timeout=5.0, transport=TRANSPORT) as client: - resp = await client.post( - cfg["host"].rstrip("/") + "/api/public/otel/v1/traces", - json=_otlp_body(spans), - headers={"x-langfuse-ingestion-version": "4"}, - auth=(cfg.get("public_key", ""), cfg.get("secret_key", "")), - ) - if resp.status_code != 200: - log.warning("langfuse ← %s %s", resp.status_code, resp.text[:200]) - return False - partial = (resp.json() or {}).get("partialSuccess") or {} - if partial.get("rejectedSpans"): - log.warning("langfuse 일부 거부: %s", partial) - return False - return True - except Exception as e: # noqa: BLE001 — 관측용이라 절대 본 흐름 안 깨뜨림 - log.warning("langfuse 전송 실패: %s: %s", type(e).__name__, e) + if not spans or not enabled(): return False + body = _otlp_body(spans) + targets: list[tuple[str, str, dict, tuple | None]] = [] + if cfg.get("host"): + targets.append(("langfuse", cfg["host"].rstrip("/") + "/api/public/otel/v1/traces", + {"x-langfuse-ingestion-version": "4"}, (cfg.get("public_key", ""), cfg.get("secret_key", "")))) + if cfg.get("phoenix"): + targets.append(("phoenix", cfg["phoenix"].rstrip("/") + "/v1/traces", {}, None)) + ok = False + for name, url, headers, auth in targets: + try: + async with httpx.AsyncClient(timeout=5.0, transport=TRANSPORT) as client: + resp = await client.post(url, json=body, headers=headers, auth=auth) + if resp.status_code != 200: + log.warning("%s ← %s %s", name, resp.status_code, resp.text[:200]) + continue + partial = {} + try: + partial = (resp.json() or {}).get("partialSuccess") or {} + except ValueError: + pass + if partial.get("rejectedSpans"): + log.warning("%s 일부 거부: %s", name, partial) + continue + ok = True + except Exception as e: # noqa: BLE001 — 관측용이라 절대 본 흐름 안 깨뜨림 + log.warning("%s 전송 실패: %s: %s", name, type(e).__name__, e) + return ok def send_later(spans: list[dict]) -> None: diff --git a/5_django_backend/config/settings.py b/5_django_backend/config/settings.py index 0ee4da5..3123fed 100644 --- a/5_django_backend/config/settings.py +++ b/5_django_backend/config/settings.py @@ -95,6 +95,9 @@ LANGFUSE = { "secret_key": _env("LANGFUSE_SECRET_KEY"), # 게이트웨이(FabriX 호출 원문) 트레이스는 기본 끔 — 켜면 질문 하나에 행이 2개(chat + fabrix) 잡힘 "gateway": _env_bool("LANGFUSE_TRACE_GATEWAY", False), + # Phoenix(도커 없이 pip 로 뜨는 관측 서버). 주소만 주면 같은 span 을 여기로도 보냄 + "phoenix": _env("PHOENIX_HOST"), + "project": _env("TRACE_PROJECT", "codeassist"), } # 세션 컨텍스트 하드 한도 — usage.limit 로 프론트 게이지에 감 CONTEXT_LIMIT_TOKENS = int(_env("CONTEXT_LIMIT_TOKENS", "128000") or "128000") diff --git a/5_django_backend/deploy.sh b/5_django_backend/deploy.sh index df07923..8148001 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; do grep -q "^$k=" .env || echo "$k=" >> .env; done # 비우면 관측 안 함 +for k in LANGFUSE_HOST LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY PHOENIX_HOST; 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 new file mode 100644 index 0000000..968d3e2 --- /dev/null +++ b/5_django_backend/deploy/phoenix/README.md @@ -0,0 +1,35 @@ +# Phoenix(Arize) — 도커 없이 -12 에 올리는 LLM 관측 서버 + +Langfuse 대신 쓰는 이유: -12 는 "컨테이너 안의 컨테이너"가 막혀 있어(CapEff a80425fb, unshare 불가) 도커를 못 씀. +Phoenix 는 pip 패키지 하나 = 파이썬 프로세스 하나. 저장은 SQLite 파일(기본). 트레이스·트리·토큰·세션·사용자·평가 다 됨. +백엔드는 `PHOENIX_HOST` 만 주면 같은 OTLP span 을 여기로 보냄(`apps/gateway/langfuse.py`, OpenInference 속성 동봉). + +## 1. 패키지 반입 (인터넷 되는 PC, 도커로 리눅스·py3.12 용 wheel 받음) + +```powershell +docker run --rm -v "$env:USERPROFILE\Desktop\phoenix-wheels:/out" python:3.12-slim-bookworm bash -c "pip download -q -d /out 'arize-phoenix[pg]'" +Compress-Archive "$env:USERPROFILE\Desktop\phoenix-wheels" "$env:USERPROFILE\Desktop\phoenix-wheels.zip" # 확장자 .txt 로 바꿔 메일 +``` + +## 2. -12 에서 설치·기동 + +```bash +cd /www && unzip -oq phoenix-wheels.zip && mkdir -p /www/phoenix && cd /www/phoenix +python3 -m venv .venv && .venv/bin/pip install --no-index --find-links /www/phoenix-wheels 'arize-phoenix[pg]' +bash /www/abap-ito/code-assistant/5_django_backend/deploy/phoenix/run.sh # nohup 으로 8915 에 뜸. 재부팅 후에도 이걸로 +curl -s http://127.0.0.1:8915/healthz +``` +run.sh 가 하는 것: `PHOENIX_PORT=8915 PHOENIX_WORKING_DIR=/www/phoenix/data` 로 `phoenix serve`. 로그 `/www/phoenix/phoenix.log`. +고객사 PostgreSQL 에 두고 싶으면 run.sh 의 `PHOENIX_SQL_DATABASE_URL` 주석 풀고 채움(스키마 `phoenix`). + +## 3. 백엔드 연결 + +- -12 `code-assistant/5_django_backend/.env`: `PHOENIX_HOST=http://127.0.0.1:8915` → `bash deploy.sh` +- -13 `abap-specgen/web/deploy/bare/.env`: `PHOENIX_HOST=http://10.196.81.34:8915` → `run.sh restart` +- 웹 UI: 고객사 PC 에서 `http://10.196.81.34:8915` (프로젝트 `codeassist` / `abap-specgen` 로 나뉨) + +## 운영 + +- 인증 기본 꺼짐(사내망). 켜려면 run.sh 에 `PHOENIX_ENABLE_AUTH=true PHOENIX_SECRET=<랜덤32자>` — 첫 로그인 admin@localhost / admin +- 중지 `pkill -f "phoenix serve"`. 데이터는 `/www/phoenix/data/` (SQLite) — 백업은 그 폴더 복사 +- 백엔드는 Phoenix 죽어 있어도 멀쩡(전송 실패는 warning 로그만) diff --git a/5_django_backend/deploy/phoenix/run.sh b/5_django_backend/deploy/phoenix/run.sh new file mode 100644 index 0000000..90ad340 --- /dev/null +++ b/5_django_backend/deploy/phoenix/run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Phoenix 기동 — /www/phoenix/.venv 에 설치돼 있다고 가정(README 2번). 이미 떠 있으면 내리고 다시. +set -e +ROOT=/www/phoenix +export PHOENIX_PORT=${PHOENIX_PORT:-8915} +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_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 +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 diff --git a/5_django_backend/docs-lib/phoenix.md b/5_django_backend/docs-lib/phoenix.md new file mode 100644 index 0000000..a0a9c24 --- /dev/null +++ b/5_django_backend/docs-lib/phoenix.md @@ -0,0 +1,21 @@ +# Phoenix(Arize) — 자체 호스팅 관측 서버 (2026-09-22 정리) + +출처: arize.com/docs/phoenix (self-hosting/configuration, tracing/custom-spans), PyPI arize-phoenix 20.15.0. + +- pip 패키지 `arize-phoenix` (Python >=3.10). 실행 `python -m phoenix.server.main serve` (= `phoenix serve`). 도커 불필요. +- 포트: 웹+OTLP/HTTP 6006(`PHOENIX_PORT`), OTLP/gRPC 4317(`PHOENIX_GRPC_PORT`). 헬스 `GET /healthz`. +- **트레이스 수신: `POST {host}/v1/traces`** — OTLP/HTTP JSON, 인증 없음(기본). 응답 200. +- 저장: 기본 SQLite(`PHOENIX_WORKING_DIR`, 기본 ~/.phoenix). PostgreSQL 은 `PHOENIX_SQL_DATABASE_URL=postgresql://u:p@host/db` + `PHOENIX_SQL_DATABASE_SCHEMA`. +- 인증: `PHOENIX_ENABLE_AUTH=true` + `PHOENIX_SECRET`. 첫 관리자 admin@localhost / `PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD`(기본 admin). +- 폐쇄망: `PHOENIX_ALLOW_EXTERNAL_RESOURCES=false`(구글 폰트 안 부름), `PHOENIX_TELEMETRY_ENABLED=false`. + +## OpenInference 속성 (Phoenix 가 읽는 것) +- 프로젝트: 리소스 속성 `openinference.project.name` (없으면 default) +- span 종류 `openinference.span.kind` = LLM | TOOL | CHAIN | AGENT | RETRIEVER … +- 입출력 `input.value`, `input.mime_type`(text/plain | application/json), `output.value`, `output.mime_type` +- 모델·토큰 `llm.model_name`, `llm.token_count.prompt`, `llm.token_count.completion`, `llm.token_count.total` +- 세션·사용자 `session.id`, `user.id` — 세션 화면은 같은 session.id 의 루트 span 들을 묶음 +- 도구 `tool.name`, 메타 `metadata`(JSON 문자열), 태그 없음(metadata 로) +- 오류: span status code 2 + message + +우리 모듈(`apps/gateway/langfuse.py`)은 langfuse.* 와 위 속성을 같은 span 에 둘 다 실어서 어느 서버로 보내도 읽힘. diff --git a/5_django_backend/tests/test_langfuse.py b/5_django_backend/tests/test_langfuse.py index 6ebd329..f46130d 100644 --- a/5_django_backend/tests/test_langfuse.py +++ b/5_django_backend/tests/test_langfuse.py @@ -107,3 +107,19 @@ async def test_finalize_sends_user_trace(lf_server, django_user_model, monkeypat assert spans[1]["startTimeUnixNano"] == "1100000000" and spans[1]["endTimeUnixNano"] == "1300000000" assert spans[2]["status"] == {"code": 2, "message": "no such file"} and a2["langfuse.observation.level"] == "ERROR" assert stream_mod is not None + + +@override_settings(LANGFUSE={"host": "", "public_key": "", "secret_key": "", "gateway": False, "phoenix": "http://px.test", "project": "codeassist"}) +async def test_phoenix_target_no_auth_and_openinference_attrs(lf_server): + root = langfuse.trace("t9", "chat", userId="u@x.com", sessionId="s9", input="질문", output="답", usage=langfuse.usage_of(7, 3), model="581") + tool = langfuse.tool_span("t9", "read", parent=root, input={"filePath": "a.md"}, output="본문") + assert await langfuse.send([root, tool]) and len(lf_server) == 1 + req = lf_server[0] + assert req["url"] == "http://px.test/v1/traces" and req["auth"] is None + res = {a["key"]: a["value"]["stringValue"] for a in req["body"]["resourceSpans"][0]["resource"]["attributes"]} + assert res["openinference.project.name"] == "codeassist" + r, tl = _attrs(req["body"]["resourceSpans"][0]["scopeSpans"][0]["spans"][0]), _attrs(req["body"]["resourceSpans"][0]["scopeSpans"][0]["spans"][1]) + assert r["openinference.span.kind"] == "LLM" and r["input.value"] == "질문" and r["output.value"] == "답" + assert r["llm.model_name"] == "581" and r["llm.token_count.total"] == "10" and r["session.id"] == "s9" and r["user.id"] == "u@x.com" + assert tl["openinference.span.kind"] == "TOOL" and tl["tool.name"] == "read" and json.loads(tl["input.value"]) == {"filePath": "a.md"} +