Files
web-page-backend/docs/superpowers/specs/2026-05-28-agent-office-docker-logs-design.md
gahusb 809eec9b15 docs(agent-office): docker logs 통합 타임라인 설계 spec
agent-office LogTab에 각 서비스의 docker 로그(액세스 + 비즈니스 이벤트)를
통합 타임라인으로 노출하는 아키텍처를 정의. 5개 서비스 공용 _shared/access_log
모듈, ring buffer 기반 /logs/recent 엔드포인트, agent-office 측 merge 로직,
3단계 phase 분리 포함.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:23:21 +09:00

363 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Agent Office — Docker 로그 기반 통합 타임라인 설계
> 작성일: 2026-05-28
> 대상: web-backend (5개 lab + agent-office) + web-ui (LogTab)
## 배경
`/agent-office` 의 각 에이전트 상세 패널에 노출되는 **로그 탭** 이 현재는 의미가 빈약하다.
- 노출 소스는 `agent-office` 의 자체 SQLite `agent_logs` 테이블 한 곳뿐.
- `base.py BaseAgent.transition()` 가 매번 `State: idle -> working ({detail})` 형식 자동 로그를 기록 — 사용자가 실제로 무슨 일이 일어났는지 파악하기 어려운 노이즈가 다수.
- 각 에이전트가 실제로 호출하는 외부 서비스 컨테이너 (lotto / stock / music-lab / insta-lab / realestate-lab) 의 docker stdout 은 LogTab 에 한 줄도 흐르지 않는다.
따라서 LogTab 에서는 “이 에이전트가 어떤 API 를 불러서 어떤 응답을 받았는지” “외부 서비스에서 어떤 비즈니스 이벤트가 발생했는지” 가 보이지 않는다.
## 목표
1. 각 에이전트 LogTab 에 **해당 서비스 컨테이너의 의미 있는 docker 로그** 를 흘려보낸다.
2. healthcheck / static / OPTIONS 같은 노이즈 로그는 **서버 측에서 미리 차단** 한다.
3. API 호출 한 줄 (`POST /api/lotto/recommend → 200 142ms`) 과 비즈니스 이벤트 (`수집 완료: new=12, total=340`) 양쪽 모두 표시한다.
4. 에이전트 내부 동작 로그 (`agent_logs` DB) 와 서비스 로그를 **한 화면에 시간순으로 통합** 한다.
5. `State: idle -> working` 형식 자동 transition 로그는 제거한다.
## 비목표
- 실시간 WebSocket push (지금은 5초 폴링이면 충분).
- 컨테이너 외부 (NAS 호스트, Windows AI 서버) 로그 수집.
- 로그 검색 / 필터 UI (당장은 단순 시간순 표시).
- 다른 lab (image-lab / tarot-lab / saju-lab / packs-lab / video-lab) 은 1차 범위에서 제외 — 5개 활성 에이전트가 가리키는 5개 컨테이너만 다룬다.
## 결정사항 요약
| 항목 | 결정 |
|---|---|
| 수집 방식 | 각 서비스가 `/logs/recent` 엔드포인트 노출 + agent-office 가 polling |
| 표시 방식 | 통합 타임라인 (agent 로그 + service 로그 시간순 merge) |
| 로그 범위 | 액세스 로그 (healthcheck 제외) + 비즈니스 이벤트 (logger.info/warning/error) |
| ring buffer 크기 | 컨테이너당 500개, in-memory deque |
| docker logs retention | `max-size 10m × max-file 3` = 서비스당 30MB |
| agent_logs DB retention | **90일** (매일 03:00 cleanup) |
| state 자동 로그 | 제거 (`base.py BaseAgent.transition()``add_log("State: ...")`) |
| 자동 수집 메커니즘 | Python `logging.Handler` 를 BufferLogHandler 로 등록 — 기존 logger.info/warning/error 호출이 자동으로 ring buffer 에 흐름 |
## 아키텍처
```
┌─────────────────────────────────────────────────────────────┐
│ web-ui (LogTab) │
│ ─ GET /api/agent-office/agents/{id}/logs?limit=N │
│ ─ 5초 폴링 (기존 refreshTrigger 흐름 재활용) │
│ ─ source 뱃지 표시 (access | log | agent) │
└─────────────────────────────────────────────────────────────┘
│ 통합 타임라인 (시간순 merge)
┌─────────────────────────────────────────────────────────────┐
│ agent-office │
│ - get_merged_logs(agent_id, limit) = │
│ agent_logs (state 로그 제외) │
│ + service_proxy.fetch_logs(container, path_prefix) │
│ → ts 기준 정렬 → 최근 N개 │
│ - 매핑: AGENT_CONTAINER_MAP │
│ stock → ("stock", "/api/(stock|trade|portfolio)") │
│ music → ("music-lab", "/api/music") │
│ insta → ("insta-lab", "/api/insta") │
│ realestate → ("realestate-lab", "/api/realestate") │
│ lotto → ("lotto-backend", "/api/lotto") │
└─────────────────────────────────────────────────────────────┘
│ GET http://{container}:{port}/logs/recent
│ ?since=ISO&limit=N&path_prefix=...
│ (내부 docker 네트워크 only, nginx public 라우팅 X)
┌─────────────────────────────────────────────────────────────┐
│ 각 서비스 컨테이너 (5개) │
│ 공용 모듈 _shared/access_log.py: │
│ - LogBuffer: collections.deque(maxlen=500) │
│ - AccessLogMiddleware: 모든 요청 후 한 줄 기록 │
│ 제외: /health /healthz /ping /favicon /docs /redoc │
│ /openapi.json /logs/recent OPTIONS HEAD │
│ - BufferLogHandler: logger.info/warning/error 자동 캡처 │
│ - /logs/recent 라우터 │
└─────────────────────────────────────────────────────────────┘
```
## 공용 모듈 — `web-backend/_shared/access_log.py`
```python
from collections import deque
from datetime import datetime
from fastapi import APIRouter, Request
from starlette.middleware.base import BaseHTTPMiddleware
import logging
import time
_BUFFER = deque(maxlen=500)
EXCLUDED_PATHS = {"/health", "/healthz", "/ping", "/favicon.ico",
"/docs", "/redoc", "/openapi.json", "/logs/recent"}
EXCLUDED_PREFIXES = ("/static/",)
EXCLUDED_METHODS = {"OPTIONS", "HEAD"}
def _should_log(request: Request) -> bool:
if request.method in EXCLUDED_METHODS:
return False
path = request.url.path
if path in EXCLUDED_PATHS:
return False
if any(path.startswith(p) for p in EXCLUDED_PREFIXES):
return False
return True
class AccessLogMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
start = time.time()
response = await call_next(request)
if not _should_log(request):
return response
elapsed_ms = int((time.time() - start) * 1000)
status = response.status_code
_BUFFER.append({
"ts": datetime.utcnow().isoformat() + "Z",
"level": "info" if status < 400 else
"warning" if status < 500 else "error",
"source": "access",
"method": request.method,
"path": request.url.path,
"status": status,
"ms": elapsed_ms,
"message": f"{request.method} {request.url.path}{status} ({elapsed_ms}ms)",
})
return response
class BufferLogHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
try:
_BUFFER.append({
"ts": datetime.utcfromtimestamp(record.created).isoformat() + "Z",
"level": record.levelname.lower(),
"source": "log",
"logger": record.name,
"message": record.getMessage(),
})
except Exception:
pass
router = APIRouter()
@router.get("/logs/recent")
def logs_recent(limit: int = 200, since: str | None = None,
path_prefix: str | None = None):
items = list(_BUFFER)
if since:
items = [x for x in items if x["ts"] > since]
if path_prefix:
items = [x for x in items
if x["source"] == "log" or x.get("path", "").startswith(path_prefix)]
return {"logs": items[-limit:]}
def install(app, logger_root: str = ""):
"""서비스 main.py 가 호출하는 단일 설치 함수."""
app.add_middleware(AccessLogMiddleware)
app.include_router(router)
logging.getLogger(logger_root).addHandler(BufferLogHandler())
```
### 각 서비스 main.py 적용
```python
from _shared.access_log import install as install_access_log
install_access_log(app)
```
## docker-compose 변경
5개 서비스 (`lotto-backend`, `stock`, `music-lab`, `insta-lab`, `realestate-lab`) 에 동일 패턴 추가:
```yaml
environment:
- PYTHONPATH=/app:/shared
volumes:
- ../_shared:/shared:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
`/logs/recent`**nginx default.conf 의 public location 블록에 추가하지 않는다**. 내부 docker 네트워크에서 `http://{container_name}:{port}/logs/recent` 로만 접근.
## agent-office 측 변경
### `app/constants.py`
```python
AGENT_CONTAINER_MAP = {
"stock": ("stock", 8000, r"^/api/(stock|trade|portfolio)"),
"music": ("music-lab", 8000, r"^/api/music"),
"insta": ("insta-lab", 8000, r"^/api/insta"),
"realestate": ("realestate-lab", 8000, r"^/api/realestate"),
"lotto": ("lotto-backend", 8000, r"^/api/lotto"),
}
```
### `app/service_proxy.py`
```python
async def fetch_service_logs(agent_id: str, since: str | None = None,
limit: int = 200) -> list[dict]:
mapping = AGENT_CONTAINER_MAP.get(agent_id)
if not mapping:
return []
host, port, path_re = mapping
url = f"http://{host}:{port}/logs/recent"
params = {"limit": limit}
if since:
params["since"] = since
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(url, params=params)
data = resp.json().get("logs", [])
except Exception as e:
logger.warning("fetch_service_logs(%s) 실패: %s", agent_id, e)
return []
# path_prefix 필터: access 로그만 path_re 검증
return [x for x in data if x["source"] == "log"
or re.match(path_re, x.get("path", ""))]
```
### `app/db.py`
```python
def get_logs(agent_id: str, limit: int = 50) -> list[dict]:
# 'State: ...' 자동 로그 제외 (사용자 요청)
rows = conn.execute("""
SELECT * FROM agent_logs
WHERE agent_id=?
AND message NOT LIKE 'State: %'
ORDER BY created_at DESC LIMIT ?
""", (agent_id, limit)).fetchall()
return [...]
def delete_old_logs(days: int = 90) -> int:
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
with _conn() as conn:
c = conn.execute("DELETE FROM agent_logs WHERE created_at < ?", (cutoff,))
return c.rowcount
```
### `app/main.py`
```python
@app.get("/api/agent-office/agents/{agent_id}/logs")
async def agent_logs(agent_id: str, limit: int = 50):
agent_items = get_logs(agent_id, limit=limit)
service_items = await fetch_service_logs(agent_id, limit=limit)
merged = sorted(agent_items + service_items,
key=lambda x: x.get("ts") or x.get("created_at"),
reverse=True)[:limit]
return {"logs": merged}
```
### `app/agents/base.py`
```python
async def transition(self, new_state, detail="", task_id=None):
# add_log(... "State: ...") 호출 삭제 — 사용자 요청
...
# ws_manager 알림은 유지
```
### `app/scheduler.py`
```python
scheduler.add_job(
lambda: delete_old_logs(days=90),
CronTrigger(hour=3, minute=0),
id="cleanup_old_logs",
)
```
## web-ui 측 변경
### `src/pages/agent-office/components/LogTab.jsx`
- log row schema 가 두 가지로 늘어남: agent_logs `{level, message, created_at}` vs service `{ts, level, source, method, path, status, ms, message}`.
- source 뱃지를 추가로 표시: `[ACCESS]` / `[LOG]` / `[AGENT]`.
- access 로그는 method + path + status + ms 를 보조 라인으로 표시.
색상 가이드:
- `source=access` 청록 (#5eead4)
- `source=log` 파랑 (#60a5fa)
- `level=warning` 노랑 (#fbbf24)
- `level=error` 빨강 (#ef4444)
- `source=agent` (agent_logs) 회색 (#9ca3af)
## Phase 분리
대규모 변경이라 단일 PR 위험. 3단계로 나눠 진행.
### Phase 1 — PoC (가장 우선)
1. `web-backend/_shared/access_log.py` 신설.
2. `web-backend/lotto/app/main.py` 한 곳에만 `install_access_log(app)` 추가.
3. `web-backend/docker-compose.yml``lotto-backend` 서비스에 PYTHONPATH + volume + logging 추가.
4. `agent-office``service_proxy.fetch_service_logs()` + `AGENT_CONTAINER_MAP` (lotto 만) + `get_logs(agent_id)` merge.
5. `LogTab.jsx` 가 source 뱃지를 표시하도록 확장.
6. base.py `State: ...` 자동 로그 제거 + `db.get_logs()` NOT LIKE 필터 추가.
검증: `/agent-office` 에서 lotto 에이전트 선택 → LogTab 에 `POST /api/lotto/...` 한 줄과 기존 logger.info 출력이 같이 보이는지.
### Phase 2 — 4개 서비스 확장
1. stock / music-lab / insta-lab / realestate-lab 의 `main.py``install_access_log(app)` 추가.
2. docker-compose 4개 서비스 동일 패턴 적용.
3. `AGENT_CONTAINER_MAP` 에 4개 매핑 추가.
4. `delete_old_logs` cleanup job 등록.
검증: 5개 에이전트 모두 LogTab 에서 의미 있는 로그 노출.
### Phase 3 — 비즈니스 이벤트 보강
디자인 4/5 의 "추가 권장" 표 항목들을 `logger.info(...)` 한 줄씩 추가. 약 1015줄.
- stock: Order 응답, AI Coach 호출, 스크리너 결과
- music-lab: 생성 시작/완료
- insta-lab: 키워드 추출 완료, 슬레이트 생성 완료, 발행 결과
- lotto-backend: AI 큐레이터 호출/응답, 점수 계산 완료
## 알려진 위험과 완화
| 위험 | 완화 |
|---|---|
| `/logs/recent` 가 외부로 노출되면 access pattern + 내부 동작 노출 | nginx public location 에 등재하지 않음 + 내부 docker 네트워크만 |
| 각 서비스의 logger 가 propagate 설정이 달라 BufferLogHandler 에 안 흐를 가능성 | `install()` 에서 `logging.getLogger("")` (root) 에 핸들러 등록 — 모든 child logger 가 자동 전파 |
| BufferLogHandler 의 `emit()` 가 다른 핸들러의 포맷팅에 영향 | `Handler.emit` 만 override, formatter 사용 안 함 |
| ring buffer 가 0.5초당 수십 건 트래픽으로 가득 차서 30초 분량밖에 안 남음 | 500개는 평소 트래픽 기준 1시간 이상 보관. 모니터링하다 부족하면 1000 으로 상향 |
| `lotto-backend` 컨테이너의 personal/blog/todo API 가 lotto 에이전트 로그에 섞임 | `AGENT_CONTAINER_MAP` 의 path_prefix 정규식으로 `/api/lotto` 만 매칭 — 다른 prefix 는 자연스럽게 필터 |
| docker-compose volume `../_shared:/shared:ro` 가 NAS 운영 환경에서 경로 차이로 깨질 가능성 | repo 의 상대경로 (`../_shared`) 는 NAS 의 `/volume1/docker/webpage/backend/_shared` 와 동일 구조로 git pull 됨. Gitea webhook 으로 push 되는 경로에 `_shared/` 디렉토리도 함께 포함됨을 deployer rsync 시 검증 |
## 변경 파일 요약
```
■ 신설
web-backend/_shared/__init__.py
web-backend/_shared/access_log.py
■ web-backend
lotto/app/main.py + install_access_log + 추가 logger.info 34개 (Phase 3)
stock/app/main.py + install_access_log + 추가 logger.info 3개 (Phase 3)
music-lab/app/main.py + install_access_log + 추가 logger.info 2개 (Phase 3)
insta-lab/app/main.py + install_access_log + 추가 logger.info 3개 (Phase 3)
realestate-lab/app/main.py + install_access_log (Phase 3 추가 없음)
docker-compose.yml 5개 서비스 PYTHONPATH/volume/logging 추가
■ web-backend/agent-office
app/service_proxy.py + fetch_service_logs(agent_id, ...)
app/main.py agent_logs 엔드포인트가 merge 사용
app/db.py + delete_old_logs + get_logs NOT LIKE 'State: %'
app/scheduler.py + 매일 03:00 cleanup job
app/agents/base.py transition() 의 add_log('State: ...') 제거
app/constants.py + AGENT_CONTAINER_MAP
■ web-ui
src/pages/agent-office/components/LogTab.jsx
source 뱃지 + access 로그 method/status/ms 표시
```