Files
LLM-Zabbix-HABR/alert-processor/app/audit_logger.py
T

330 lines
10 KiB
Python

from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from redis.asyncio import Redis
from app.models import NotificationDecision, ProcessorForwardEnvelope
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
class AuditLogger:
def __init__(
self,
client: Redis,
key_prefix: str,
ttl_seconds: int = 604800,
max_stage_records: int = 200,
) -> None:
self.client = client
self.key_prefix = key_prefix
self.ttl_seconds = ttl_seconds
self.max_stage_records = max_stage_records
def _journal_key(self, correlation_id: str) -> str:
return f"{self.key_prefix}:journal:{correlation_id}"
def _stages_key(self, correlation_id: str) -> str:
return f"{self.key_prefix}:stages:{correlation_id}"
def _event_index_key(self, event_id: str) -> str:
return f"{self.key_prefix}:event:{event_id}"
def _recent_key(self) -> str:
return f"{self.key_prefix}:recent"
async def log_ingest_queued(
self,
envelope: ProcessorForwardEnvelope,
job_id: str,
queue_name: str,
) -> None:
event = envelope.event
correlation_id = event.correlation_id
event_id = event.event_id or ""
await self._upsert_journal(
correlation_id=correlation_id,
event_id=event_id or None,
fields={
"state": "queued",
"job_id": job_id,
"queue_name": queue_name,
"correlation_id": correlation_id,
"event_id": event_id,
"host": event.host or "",
"service": event.service or "",
"trigger_name": event.trigger_name or "",
"severity": event.severity or "",
"queued_at": _utc_now(),
"updated_at": _utc_now(),
},
)
await self.log_stage(
correlation_id=correlation_id,
event_id=event.event_id,
stage="ingest_queued",
status="ok",
details={
"job_id": job_id,
"queue_name": queue_name,
},
)
async def log_worker_started(
self,
envelope: ProcessorForwardEnvelope,
job_id: str,
attempt: int,
identity: str,
) -> None:
event = envelope.event
await self._upsert_journal(
correlation_id=event.correlation_id,
event_id=event.event_id,
fields={
"state": "processing",
"worker_started_at": _utc_now(),
"worker_attempt": attempt,
"processing_identity": identity,
"updated_at": _utc_now(),
},
)
await self.log_stage(
correlation_id=event.correlation_id,
event_id=event.event_id,
stage="worker_started",
status="ok",
details={
"job_id": job_id,
"attempt": attempt,
"identity": identity,
},
)
async def log_stage(
self,
correlation_id: str,
event_id: str | None,
stage: str,
status: str = "ok",
details: dict[str, Any] | None = None,
) -> None:
payload = {
"ts": _utc_now(),
"stage": stage,
"status": status,
"details": details or {},
}
raw = json.dumps(payload, ensure_ascii=False)
stages_key = self._stages_key(correlation_id)
await self.client.rpush(stages_key, raw)
await self.client.ltrim(stages_key, -self.max_stage_records, -1)
await self.client.expire(stages_key, self.ttl_seconds)
await self._upsert_journal(
correlation_id=correlation_id,
event_id=event_id,
fields={
"last_stage": stage,
"last_stage_status": status,
"updated_at": _utc_now(),
},
)
async def log_decision(
self,
envelope: ProcessorForwardEnvelope,
decision: NotificationDecision,
) -> None:
event = envelope.event
await self._upsert_journal(
correlation_id=event.correlation_id,
event_id=event.event_id,
fields={
"state": "decision_made",
"decision_at": _utc_now(),
"notify": str(decision.notify).lower(),
"suppressed": str(decision.suppressed).lower(),
"routing_class": decision.routing_class or "",
"decision_reason": decision.reason or "",
"decision_json": json.dumps(
decision.model_dump(mode="json"),
ensure_ascii=False,
),
"updated_at": _utc_now(),
},
)
await self.log_stage(
correlation_id=event.correlation_id,
event_id=event.event_id,
stage="decision_made",
status="ok",
details={
"notify": decision.notify,
"suppressed": decision.suppressed,
"routing_class": decision.routing_class,
"reason": decision.reason,
},
)
async def log_delivery(
self,
envelope: ProcessorForwardEnvelope,
delivery_payload: dict[str, Any],
) -> None:
event = envelope.event
await self._upsert_journal(
correlation_id=event.correlation_id,
event_id=event.event_id,
fields={
"state": "delivered",
"delivery_at": _utc_now(),
"delivery_json": json.dumps(delivery_payload, ensure_ascii=False),
"updated_at": _utc_now(),
},
)
await self.log_stage(
correlation_id=event.correlation_id,
event_id=event.event_id,
stage="delivery_completed",
status="ok",
details=delivery_payload,
)
async def log_worker_outcome(
self,
envelope: ProcessorForwardEnvelope,
state: str,
error: str | None = None,
attempt: int | None = None,
) -> None:
event = envelope.event
fields = {
"state": state,
"updated_at": _utc_now(),
}
if error:
fields["last_error"] = error
fields["last_error_at"] = _utc_now()
if attempt is not None:
fields["worker_attempt"] = attempt
await self._upsert_journal(
correlation_id=event.correlation_id,
event_id=event.event_id,
fields=fields,
)
await self.log_stage(
correlation_id=event.correlation_id,
event_id=event.event_id,
stage=f"worker_{state}",
status="error" if error else "ok",
details={
"attempt": attempt,
"error": error,
},
)
async def get_event_audit(
self,
correlation_id: str | None = None,
event_id: str | None = None,
) -> dict[str, Any] | None:
resolved_correlation = correlation_id
if not resolved_correlation and event_id:
value = await self.client.get(self._event_index_key(event_id))
if value is None:
return None
resolved_correlation = value.decode() if isinstance(value, bytes) else str(value)
if not resolved_correlation:
return None
journal_raw = await self.client.hgetall(self._journal_key(resolved_correlation))
if not journal_raw:
return None
stages_raw = await self.client.lrange(self._stages_key(resolved_correlation), 0, -1)
journal = self._decode_hash(journal_raw)
stages = [json.loads(item.decode() if isinstance(item, bytes) else str(item)) for item in stages_raw]
if journal.get("decision_json"):
try:
journal["decision"] = json.loads(journal["decision_json"])
except Exception:
journal["decision"] = None
if journal.get("delivery_json"):
try:
journal["delivery"] = json.loads(journal["delivery_json"])
except Exception:
journal["delivery"] = None
return {
"journal": journal,
"stages": stages,
}
async def list_recent(self, limit: int = 20) -> list[dict[str, Any]]:
correlation_ids_raw = await self.client.zrevrange(self._recent_key(), 0, max(limit - 1, 0))
results: list[dict[str, Any]] = []
for raw in correlation_ids_raw:
correlation_id = raw.decode() if isinstance(raw, bytes) else str(raw)
audit = await self.get_event_audit(correlation_id=correlation_id)
if audit is not None:
results.append(audit)
return results
async def _upsert_journal(
self,
correlation_id: str,
event_id: str | None,
fields: dict[str, Any],
) -> None:
journal_key = self._journal_key(correlation_id)
mapping: dict[str, str] = {}
for key, value in fields.items():
if value is None:
continue
mapping[key] = str(value)
await self.client.hset(journal_key, mapping=mapping)
await self.client.expire(journal_key, self.ttl_seconds)
if event_id:
await self.client.set(
self._event_index_key(event_id),
correlation_id,
ex=self.ttl_seconds,
)
await self.client.zadd(self._recent_key(), {correlation_id: datetime.now(timezone.utc).timestamp()})
await self.client.expire(self._recent_key(), self.ttl_seconds)
@staticmethod
def _decode_hash(raw: dict[bytes, bytes]) -> dict[str, str]:
result: dict[str, str] = {}
for k, v in raw.items():
key = k.decode() if isinstance(k, bytes) else str(k)
value = v.decode() if isinstance(v, bytes) else str(v)
result[key] = value
return result