feat(agent-office): fetch_service_logs 추가 (path_prefix 정규식 필터)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 02:42:23 +09:00
parent 1d723764b4
commit ed30790f22
2 changed files with 91 additions and 0 deletions

View File

@@ -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 "")
]

View File

@@ -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 == []