import base64 import binascii from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ..db import get_session from ..models import Event from ..schemas import ( ID_PATTERN, EventBatchAcceptedResponse, EventBatchRequest, EventsPage, StoredCheckResultEvent, ) from ..services.ingestion import ingest_event from ..timeutil import to_utc router = APIRouter() def _encode_cursor(received_at: datetime, event_id) -> str: raw = f"{to_utc(received_at).isoformat()}|{event_id}".encode() return base64.urlsafe_b64encode(raw).decode().rstrip("=") def _decode_cursor(cursor: str) -> tuple[datetime, str]: pad = "=" * (-len(cursor) % 4) try: raw = base64.urlsafe_b64decode(cursor + pad).decode() ts_str, eid = raw.split("|", 1) return to_utc(datetime.fromisoformat(ts_str)), eid except (binascii.Error, ValueError) as exc: raise HTTPException(status_code=400, detail="invalid cursor") from exc @router.post( "/events", response_model=EventBatchAcceptedResponse, status_code=status.HTTP_202_ACCEPTED, ) async def events( body: EventBatchRequest, session: AsyncSession = Depends(get_session) ) -> EventBatchAcceptedResponse: accepted = 0 deduplicated = 0 for ev in body.events: ok = await ingest_event(session, body.agent_id, ev) if ok: accepted += 1 else: deduplicated += 1 await session.commit() return EventBatchAcceptedResponse(accepted=accepted, deduplicated=deduplicated) @router.get("/events/query", response_model=EventsPage) async def query_events( agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN), check_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN), cursor: str | None = Query(default=None, max_length=512), limit: int = Query(default=100, ge=1, le=500), session: AsyncSession = Depends(get_session), ) -> EventsPage: stmt = select(Event).order_by(Event.received_at.desc(), Event.event_id.desc()) if agent_id is not None: stmt = stmt.where(Event.agent_id == agent_id) if check_id is not None: stmt = stmt.where(Event.check_id == check_id) if cursor is not None: ts, eid = _decode_cursor(cursor) stmt = stmt.where( or_( Event.received_at < ts, and_(Event.received_at == ts, Event.event_id < eid), ) ) stmt = stmt.limit(limit + 1) rows = (await session.execute(stmt)).scalars().all() next_cursor = None if len(rows) > limit: rows = rows[:limit] next_cursor = _encode_cursor(rows[-1].received_at, rows[-1].event_id) items = [ StoredCheckResultEvent( event_id=str(r.event_id), agent_id=r.agent_id, check_id=r.check_id, observed_at=to_utc(r.observed_at), received_at=to_utc(r.received_at), status=r.status, exit_code=r.exit_code, duration_ms=r.duration_ms, output=r.output, output_truncated=r.output_truncated, notifications_enabled=r.notifications_enabled, incident_key=r.incident_key, ) for r in rows ] return EventsPage(items=items, next_cursor=next_cursor)