release: bump version to 0.69.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
linkong
2026-06-03 17:27:00 +08:00
parent 06aca980d0
commit acbbfdf9e2
57 changed files with 5907 additions and 316 deletions

View File

@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Any
from app.core.security import redis_client
from app.models.system_log import AuditLog, SystemLog
from app.models.system_log import AuditLog, ObservabilityEvent, ObservabilityEventGroup, SystemLog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -120,6 +120,10 @@ class DailyLogMarker:
dominant_level: str
def _normalize_search_query(search: str | None) -> str:
return (search or "").strip().lower()
def _planet_state_dir() -> Path:
configured = os.getenv("PLANET_STATE_DIR")
if configured:
@@ -660,6 +664,228 @@ async def read_database_log_snapshot(
}
def _observability_group_matches(
group: ObservabilityEventGroup,
*,
selected_levels: tuple[str, ...],
start_date: str | None,
end_date: str | None,
search: str | None,
) -> bool:
if selected_levels and group.last_level not in selected_levels:
return False
if start_date or end_date:
if group.last_seen_at is None:
return False
date_token = group.last_seen_at.astimezone(UTC).date().isoformat()
if start_date and date_token < start_date:
return False
if end_date and date_token > end_date:
return False
query = _normalize_search_query(search)
if not query:
return True
haystack = " ".join(
[
group.fingerprint or "",
group.source or "",
group.service or "",
group.module or "",
group.category or "",
group.event or "",
group.last_level or "",
group.sample_message or "",
group.sample_detail or "",
json.dumps(group.affected_sources or [], ensure_ascii=False, sort_keys=True),
]
).lower()
return query in haystack
def _serialize_observability_group(group: ObservabilityEventGroup) -> dict[str, Any]:
return {
"fingerprint": group.fingerprint,
"source": group.source,
"service": group.service,
"module": group.module,
"category": group.category,
"event": group.event,
"level": group.last_level,
"message": group.sample_message,
"detail": group.sample_detail,
"affected_sources": group.affected_sources or [],
"count": group.count or 0,
"first_seen_at": group.first_seen_at.isoformat() if group.first_seen_at else None,
"last_seen_at": group.last_seen_at.isoformat() if group.last_seen_at else None,
}
def _serialize_observability_event(record: ObservabilityEvent) -> dict[str, Any]:
return {
"id": record.id,
"source": record.source,
"service": record.service,
"module": record.module,
"category": record.category,
"event": record.event,
"level": record.level,
"message": record.message,
"fingerprint": record.fingerprint,
"occurred_at": record.occurred_at.isoformat() if record.occurred_at else None,
"request_id": record.request_id,
"trace_id": record.trace_id,
"task_id": record.task_id,
"source_id": record.source_ref_id,
"provider": record.provider,
"user_id": record.user_id,
"context": record.context or {},
"occurrence_count": record.occurrence_count or 1,
}
async def read_observability_groups(
*,
limit: int,
level: str = LOG_LEVEL_ALL,
levels: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
search: str | None = None,
db: AsyncSession,
) -> dict[str, Any]:
selected_levels = normalize_log_levels(level, levels)
scan_limit = max(limit * 5, limit, DEFAULT_LOG_LINE_LIMIT)
result = await db.execute(
select(ObservabilityEventGroup)
.order_by(ObservabilityEventGroup.last_seen_at.desc().nullslast())
.limit(scan_limit)
)
groups = [
group
for group in result.scalars().all()
if _observability_group_matches(
group,
selected_levels=selected_levels,
start_date=start_date,
end_date=end_date,
search=search,
)
][:limit]
return {
"mode": "grouped",
"line_limit": limit,
"line_count": len(groups),
"groups": [_serialize_observability_group(group) for group in groups],
"filters": {
"level": level,
"levels": list(selected_levels),
"start_date": start_date,
"end_date": end_date,
"search": search or "",
},
}
async def read_observability_group_events(
fingerprint: str,
*,
limit: int,
db: AsyncSession,
) -> dict[str, Any] | None:
group = await db.get(ObservabilityEventGroup, fingerprint)
if group is None:
return None
result = await db.execute(
select(ObservabilityEvent)
.where(ObservabilityEvent.fingerprint == fingerprint)
.order_by(ObservabilityEvent.occurred_at.desc().nullslast(), ObservabilityEvent.id.desc())
.limit(limit)
)
events = list(reversed(result.scalars().all()))
return {
"fingerprint": fingerprint,
"group": _serialize_observability_group(group),
"line_limit": limit,
"line_count": len(events),
"events": [_serialize_observability_event(record) for record in events],
}
async def read_observability_raw_events(
*,
limit: int,
level: str = LOG_LEVEL_ALL,
levels: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
search: str | None = None,
db: AsyncSession,
) -> dict[str, Any]:
selected_levels = normalize_log_levels(level, levels)
query = select(ObservabilityEvent).order_by(ObservabilityEvent.occurred_at.desc().nullslast(), ObservabilityEvent.id.desc())
if selected_levels:
query = query.where(ObservabilityEvent.level.in_(selected_levels))
result = await db.execute(query.limit(max(limit * 5, limit)))
records = result.scalars().all()
search_query = _normalize_search_query(search)
visible: list[ObservabilityEvent] = []
for record in records:
if start_date or end_date:
if record.occurred_at is None:
continue
date_token = record.occurred_at.astimezone(UTC).date().isoformat()
if start_date and date_token < start_date:
continue
if end_date and date_token > end_date:
continue
if search_query:
haystack = " ".join(
[
record.source or "",
record.service or "",
record.module or "",
record.category or "",
record.event or "",
record.message or "",
record.fingerprint or "",
record.request_id or "",
record.trace_id or "",
record.task_id or "",
record.source_ref_id or "",
record.provider or "",
json.dumps(record.context or {}, ensure_ascii=False, sort_keys=True),
]
).lower()
if search_query not in haystack:
continue
visible.append(record)
if len(visible) >= limit:
break
visible = list(reversed(visible))
return {
"mode": "raw",
"line_limit": limit,
"line_count": len(visible),
"events": [_serialize_observability_event(record) for record in visible],
"lines": [
" ".join(
part
for part in [
record.occurred_at.isoformat() if record.occurred_at else "",
record.level.upper(),
record.source,
record.category or "",
record.event or "",
f"fingerprint={record.fingerprint}",
record.message,
]
if part
)
for record in visible
],
}
def _stable_hash(value: str) -> str:
return hashlib.sha1(value.encode("utf-8", errors="replace")).hexdigest()[:16]