feat(observability): Phoenix 대상 추가 — 같은 span 에 OpenInference 속성 동봉, PHOENIX_HOST 로 /v1/traces. -12 는 도커 불가라 pip 로 뜨는 Phoenix 로 감(deploy/phoenix)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-22 15:39:08 +09:00
co-authored by Claude Fable 5.1
parent 340e8e4beb
commit e60c5bc273
8 changed files with 162 additions and 26 deletions
+2
View File
@@ -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
+67 -25
View File
@@ -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:
+3
View File
@@ -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")
+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; 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
+35
View File
@@ -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 로그만)
+17
View File
@@ -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
+21
View File
@@ -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 에 둘 다 실어서 어느 서버로 보내도 읽힘.
+16
View File
@@ -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"}