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:
co-authored by
Claude Fable 5.1
parent
340e8e4beb
commit
e60c5bc273
@@ -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
|
||||
|
||||
@@ -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:
|
||||
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(
|
||||
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", "")),
|
||||
)
|
||||
resp = await client.post(url, json=body, headers=headers, auth=auth)
|
||||
if resp.status_code != 200:
|
||||
log.warning("langfuse ← %s %s", resp.status_code, resp.text[:200])
|
||||
return False
|
||||
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("langfuse 일부 거부: %s", partial)
|
||||
return False
|
||||
return True
|
||||
log.warning("%s 일부 거부: %s", name, partial)
|
||||
continue
|
||||
ok = True
|
||||
except Exception as e: # noqa: BLE001 — 관측용이라 절대 본 흐름 안 깨뜨림
|
||||
log.warning("langfuse 전송 실패: %s: %s", type(e).__name__, e)
|
||||
return False
|
||||
log.warning("%s 전송 실패: %s: %s", name, type(e).__name__, e)
|
||||
return ok
|
||||
|
||||
|
||||
def send_later(spans: list[dict]) -> None:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 로그만)
|
||||
@@ -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
|
||||
@@ -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 에 둘 다 실어서 어느 서버로 보내도 읽힘.
|
||||
@@ -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"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user