feat(logs): unify filtering across list and stats

Make runtime log queries support request correlation and date-range diagnostics with shared filtering semantics so the log page can use one consistent contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 22:11:41 +08:00
parent 204cb223a3
commit a27736a832
4 changed files with 446 additions and 132 deletions

View File

@@ -4,10 +4,10 @@
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Optional
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, desc, func
from sqlalchemy import select, and_, desc, func, or_
from app.models.log import Log, LogType, LogLevel
logger = logging.getLogger(__name__)
@@ -21,6 +21,19 @@ LEVEL_MAP = {
}
def parse_datetime_filter(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
normalized = value.strip()
if not normalized:
return None
normalized = normalized.replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
class LogService:
def __init__(self, db: AsyncSession):
self.db = db
@@ -34,16 +47,28 @@ class LogService:
source: Optional[str] = None,
details: Optional[dict] = None,
duration_ms: Optional[int] = None,
request_id: Optional[str] = None,
route: Optional[str] = None,
method: Optional[str] = None,
status_code: Optional[int] = None,
error_type: Optional[str] = None,
operation: Optional[str] = None,
) -> Log:
"""记录日志"""
log_entry = Log(
level=level,
type=log_type,
user_id=user_id,
request_id=request_id,
route=route,
method=method,
status_code=status_code,
error_type=error_type,
operation=operation,
message=message,
source=source,
details=json.dumps(details, ensure_ascii=False) if details else None,
duration_ms=str(duration_ms) if duration_ms else None,
details=json.dumps(details, ensure_ascii=False) if details is not None else None,
duration_ms=int(duration_ms) if duration_ms is not None else None,
)
self.db.add(log_entry)
await self.db.commit()
@@ -75,15 +100,30 @@ class LogService:
level: str = "info",
source: Optional[str] = None,
details: Optional[dict] = None,
user_id: Optional[str] = None,
request_id: Optional[str] = None,
route: Optional[str] = None,
method: Optional[str] = None,
status_code: Optional[int] = None,
error_type: Optional[str] = None,
operation: Optional[str] = None,
duration_ms: Optional[int] = None,
) -> Log:
"""记录系统运行日志"""
return await self.log(
message=message,
level=level,
log_type="system",
user_id=None,
user_id=user_id,
source=source,
details=details,
request_id=request_id,
route=route,
method=method,
status_code=status_code,
error_type=error_type,
operation=operation,
duration_ms=duration_ms,
)
async def chat_log(
@@ -104,12 +144,56 @@ class LogService:
duration_ms=duration_ms,
)
def _build_conditions(
self,
log_type: Optional[str] = None,
level: Optional[str] = None,
user_id: Optional[str] = None,
source: Optional[str] = None,
request_id: Optional[str] = None,
route: Optional[str] = None,
operation: Optional[str] = None,
status_code: Optional[int] = None,
start_at: Optional[datetime] = None,
end_at: Optional[datetime] = None,
) -> list[Any]:
conditions = []
if log_type:
conditions.append(Log.type == log_type)
if level:
conditions.append(Log.level == level)
if user_id:
conditions.append(or_(Log.user_id == user_id, Log.user_id.is_(None)))
if source:
conditions.append(Log.source == source)
if request_id:
conditions.append(Log.request_id == request_id)
if route:
conditions.append(Log.route == route)
if operation:
conditions.append(Log.operation == operation)
if status_code is not None:
conditions.append(Log.status_code == status_code)
if start_at is not None:
conditions.append(Log.created_at >= start_at)
if end_at is not None:
conditions.append(Log.created_at <= end_at)
return conditions
async def list_logs(
self,
log_type: Optional[str] = None,
level: Optional[str] = None,
user_id: Optional[str] = None,
source: Optional[str] = None,
request_id: Optional[str] = None,
route: Optional[str] = None,
operation: Optional[str] = None,
status_code: Optional[int] = None,
start_at: Optional[datetime] = None,
end_at: Optional[datetime] = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[Log], int]:
@@ -119,28 +203,27 @@ class LogService:
Returns:
(logs, total_count)
"""
conditions = []
conditions = self._build_conditions(
log_type=log_type,
level=level,
user_id=user_id,
source=source,
request_id=request_id,
route=route,
operation=operation,
status_code=status_code,
start_at=start_at,
end_at=end_at,
)
if log_type:
conditions.append(Log.type == log_type)
if level:
conditions.append(Log.level == level)
if user_id:
conditions.append(Log.user_id == user_id)
if source:
conditions.append(Log.source == source)
# 统计总数
count_query = select(func.count(Log.id))
if conditions:
count_query = count_query.where(and_(*conditions))
total_result = await self.db.execute(count_query)
total = total_result.scalar() or 0
# 查询列表
query = (
select(Log)
.where(and_(*conditions)) if conditions else select(Log)
select(Log).where(and_(*conditions)) if conditions else select(Log)
).order_by(desc(Log.created_at)).limit(limit).offset(offset)
result = await self.db.execute(query)
@@ -151,28 +234,48 @@ class LogService:
async def get_recent_logs(
self,
log_type: Optional[str] = None,
user_id: Optional[str] = None,
hours: int = 24,
limit: int = 100,
) -> list[Log]:
"""获取最近的日志"""
since = datetime.utcnow() - timedelta(hours=hours)
conditions = [Log.created_at >= since]
if log_type:
conditions.append(Log.type == log_type)
query = (
select(Log)
.where(and_(*conditions))
.order_by(desc(Log.created_at))
.limit(limit)
end_at = datetime.now(timezone.utc).replace(tzinfo=None)
start_at = end_at - timedelta(hours=hours)
conditions = self._build_conditions(
log_type=log_type,
user_id=user_id,
start_at=start_at,
end_at=end_at,
)
query = select(Log).where(and_(*conditions)).order_by(desc(Log.created_at)).limit(limit)
result = await self.db.execute(query)
return list(result.scalars().all())
async def get_log_stats(self, hours: int = 24) -> dict:
async def get_log_stats(
self,
log_type: Optional[str] = None,
level: Optional[str] = None,
user_id: Optional[str] = None,
source: Optional[str] = None,
request_id: Optional[str] = None,
route: Optional[str] = None,
operation: Optional[str] = None,
status_code: Optional[int] = None,
start_at: Optional[datetime] = None,
end_at: Optional[datetime] = None,
) -> dict:
"""获取日志统计"""
since = datetime.utcnow() - timedelta(hours=hours)
base_conditions = self._build_conditions(
user_id=user_id,
source=source,
request_id=request_id,
route=route,
operation=operation,
status_code=status_code,
start_at=start_at,
end_at=end_at,
)
stats = {
"total": 0,
@@ -180,83 +283,59 @@ class LogService:
"by_level": {"debug": 0, "info": 0, "warning": 0, "error": 0},
}
# 按类型统计
for log_type in ["agent", "system", "chat"]:
query = select(func.count(Log.id)).where(
and_(Log.type == log_type, Log.created_at >= since)
)
result = await self.db.execute(query)
count = result.scalar() or 0
stats["by_type"][log_type] = count
stats["total"] += count
total_conditions = list(base_conditions)
if log_type:
total_conditions.append(Log.type == log_type)
if level:
total_conditions.append(Log.level == level)
total_query = select(func.count(Log.id)).where(and_(*total_conditions))
total_result = await self.db.execute(total_query)
stats["total"] = total_result.scalar() or 0
# 按级别统计
for level in ["debug", "info", "warning", "error"]:
query = select(func.count(Log.id)).where(
and_(Log.level == level, Log.created_at >= since)
)
for current_type in ["agent", "system", "chat"]:
conditions = list(base_conditions)
conditions.append(Log.type == current_type)
if level:
conditions.append(Log.level == level)
query = select(func.count(Log.id)).where(and_(*conditions))
result = await self.db.execute(query)
stats["by_level"][level] = result.scalar() or 0
stats["by_type"][current_type] = result.scalar() or 0
for current_level in ["debug", "info", "warning", "error"]:
conditions = list(base_conditions)
if log_type:
conditions.append(Log.type == log_type)
conditions.append(Log.level == current_level)
query = select(func.count(Log.id)).where(and_(*conditions))
result = await self.db.execute(query)
stats["by_level"][current_level] = result.scalar() or 0
return stats
# 全局日志记录函数,方便各处调用
_global_db_session = None
def set_log_session(db: AsyncSession):
"""设置全局日志会话"""
global _global_db_session
_global_db_session = db
def get_log_session() -> Optional[AsyncSession]:
"""获取全局日志会话"""
return _global_db_session
async def log_agent_event(
message: str,
user_id: Optional[str] = None,
source: Optional[str] = None,
details: Optional[dict] = None,
duration_ms: Optional[int] = None,
):
"""记录智能体事件到数据库"""
if _global_db_session:
def serialize_log(log: Log) -> dict[str, Any]:
details = None
if log.details:
try:
svc = LogService(_global_db_session)
await svc.agent_log(message, user_id, source, details, duration_ms)
except Exception as e:
logger.error(f"Failed to log agent event: {e}")
details = json.loads(log.details)
except json.JSONDecodeError:
details = {"raw": log.details}
async def log_system_event(
message: str,
level: str = "info",
source: Optional[str] = None,
details: Optional[dict] = None,
):
"""记录系统事件到数据库"""
if _global_db_session:
try:
svc = LogService(_global_db_session)
await svc.system_log(message, level, source, details)
except Exception as e:
logger.error(f"Failed to log system event: {e}")
async def log_chat_event(
message: str,
user_id: str,
details: Optional[dict] = None,
duration_ms: Optional[int] = None,
):
"""记录聊天事件到数据库"""
if _global_db_session:
try:
svc = LogService(_global_db_session)
await svc.chat_log(message, user_id, details, duration_ms)
except Exception as e:
logger.error(f"Failed to log chat event: {e}")
return {
"id": log.id,
"level": log.level,
"type": log.type,
"user_id": log.user_id,
"request_id": log.request_id,
"route": log.route,
"method": log.method,
"status_code": log.status_code,
"error_type": log.error_type,
"operation": log.operation,
"message": log.message,
"source": log.source,
"details": details,
"duration_ms": int(log.duration_ms) if log.duration_ms is not None else None,
"created_at": log.created_at.replace(tzinfo=timezone.utc).isoformat() if log.created_at else None,
"updated_at": log.updated_at.replace(tzinfo=timezone.utc).isoformat() if log.updated_at else None,
}