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

16 KiB
Raw Blame History

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

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 적용

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) 에 동일 패턴 추가:

environment:
  - PYTHONPATH=/app:/shared
volumes:
  - ../_shared:/shared:ro
logging:
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "3"

/logs/recentnginx default.conf 의 public location 블록에 추가하지 않는다. 내부 docker 네트워크에서 http://{container_name}:{port}/logs/recent 로만 접근.

agent-office 측 변경

app/constants.py

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

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

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

@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

async def transition(self, new_state, detail="", task_id=None):
    # add_log(... "State: ...") 호출 삭제 — 사용자 요청
    ...
    # ws_manager 알림은 유지

app/scheduler.py

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.ymllotto-backend 서비스에 PYTHONPATH + volume + logging 추가.
  4. agent-officeservice_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.pyinstall_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 표시