diff --git a/agent-office/app/service_proxy.py b/agent-office/app/service_proxy.py index b8dd7c6..4db5dd1 100644 --- a/agent-office/app/service_proxy.py +++ b/agent-office/app/service_proxy.py @@ -1,8 +1,11 @@ import httpx +import logging from typing import Any, Dict, List, Optional from .config import STOCK_URL, MUSIC_LAB_URL, INSTA_LAB_URL, REALESTATE_LAB_URL +logger = logging.getLogger(__name__) + _client = httpx.AsyncClient(timeout=30.0) async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[str, Any]]: @@ -394,3 +397,38 @@ async def lotto_evolver_evaluate() -> Dict[str, Any]: resp = await client.post(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/evaluate-now") resp.raise_for_status() return resp.json() + + +from .config import AGENT_CONTAINER_MAP + + +async def fetch_service_logs( + agent_id: str, + since: Optional[str] = None, + limit: int = 200, +) -> List[Dict[str, Any]]: + """해당 에이전트가 가리키는 컨테이너의 /logs/recent 를 호출해서 + path_prefix 정규식으로 필터한 결과를 반환. + + 네트워크 실패 시 빈 리스트를 반환하고 warning 만 남김 (LogTab 이 죽지 않게). + """ + mapping = AGENT_CONTAINER_MAP.get(agent_id) + if not mapping: + return [] + host, port, path_re = mapping + url = f"http://{host}:{port}/logs/recent" + params: Dict[str, Any] = {"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 [] + return [ + x for x in data + if x.get("source") == "log" + or path_re.match(x.get("path", "") or "") + ] diff --git a/agent-office/tests/test_service_proxy_logs.py b/agent-office/tests/test_service_proxy_logs.py new file mode 100644 index 0000000..c6675c9 --- /dev/null +++ b/agent-office/tests/test_service_proxy_logs.py @@ -0,0 +1,53 @@ +import pytest +import respx +import httpx + +from app.service_proxy import fetch_service_logs + + +@pytest.mark.asyncio +@respx.mock +async def test_fetch_service_logs_filters_by_path_prefix(): + # lotto 컨테이너 응답: lotto + personal 섞임 + respx.get("http://lotto:8000/logs/recent").mock( + return_value=httpx.Response(200, json={ + "logs": [ + {"ts": "2026-05-28T10:00:00Z", "source": "access", + "method": "GET", "path": "/api/lotto/recommend", + "status": 200, "ms": 12, + "message": "GET /api/lotto/recommend → 200 (12ms)"}, + {"ts": "2026-05-28T10:00:01Z", "source": "access", + "method": "GET", "path": "/api/blog/posts", + "status": 200, "ms": 5, + "message": "GET /api/blog/posts → 200 (5ms)"}, + {"ts": "2026-05-28T10:00:02Z", "source": "log", + "logger": "lotto", "level": "info", + "message": "성과 통계 캐시 갱신"}, + ] + }) + ) + + result = await fetch_service_logs("lotto", limit=50) + # lotto path 와 모든 log 이벤트만 통과 + paths = [x.get("path") for x in result] + assert "/api/lotto/recommend" in paths + assert "/api/blog/posts" not in paths + # 비즈니스 로그도 포함 + assert any(x["source"] == "log" and x["message"] == "성과 통계 캐시 갱신" + for x in result) + + +@pytest.mark.asyncio +async def test_fetch_service_logs_unknown_agent_returns_empty(): + result = await fetch_service_logs("nonexistent", limit=50) + assert result == [] + + +@pytest.mark.asyncio +@respx.mock +async def test_fetch_service_logs_handles_connection_error(): + respx.get("http://lotto:8000/logs/recent").mock( + side_effect=httpx.ConnectError("connection refused") + ) + result = await fetch_service_logs("lotto", limit=50) + assert result == []