Improve timestamp handling and event navigation

This commit is contained in:
Stanislav Rossovskii
2026-05-27 11:01:27 +04:00
parent edc51e9c59
commit 71f0035b0b
37 changed files with 485 additions and 109 deletions

View File

@@ -6,6 +6,7 @@ from ..cursors import decode, encode
from ..db import get_session
from ..models import Agent as AgentModel
from ..schemas import Agent, AgentsPage
from ..timeutil import to_utc
router = APIRouter()
@@ -15,7 +16,7 @@ def _to_schema(a: AgentModel) -> Agent:
agent_id=a.agent_id,
hostname=a.hostname,
status=a.status,
last_seen_at=a.last_seen_at,
last_seen_at=to_utc(a.last_seen_at),
features=a.features or {"push": False, "metrics": False},
labels=a.labels or {},
)

View File

@@ -6,6 +6,7 @@ from ..cursors import decode, encode
from ..db import get_session
from ..models import Check
from ..schemas import ID_PATTERN, ChecksPage, CheckState
from ..timeutil import to_utc
router = APIRouter()
@@ -38,7 +39,7 @@ async def list_checks(
agent_id=r.agent_id,
check_id=r.check_id,
status=r.status,
last_observed_at=r.last_observed_at,
last_observed_at=to_utc(r.last_observed_at),
exit_code=r.exit_code,
incident_key=r.incident_key,
)

View File

@@ -16,12 +16,13 @@ from ..schemas import (
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"{received_at.isoformat()}|{event_id}".encode()
raw = f"{to_utc(received_at).isoformat()}|{event_id}".encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
@@ -30,7 +31,7 @@ def _decode_cursor(cursor: str) -> tuple[datetime, str]:
try:
raw = base64.urlsafe_b64decode(cursor + pad).decode()
ts_str, eid = raw.split("|", 1)
return datetime.fromisoformat(ts_str), eid
return to_utc(datetime.fromisoformat(ts_str)), eid
except (binascii.Error, ValueError) as exc:
raise HTTPException(status_code=400, detail="invalid cursor") from exc
@@ -87,8 +88,8 @@ async def query_events(
event_id=str(r.event_id),
agent_id=r.agent_id,
check_id=r.check_id,
observed_at=r.observed_at,
received_at=r.received_at,
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,

View File

@@ -6,6 +6,7 @@ from ..cursors import decode, decode_datetime, encode
from ..db import get_session
from ..models import Incident as IncidentModel
from ..schemas import Incident, IncidentsPage
from ..timeutil import to_utc
router = APIRouter()
@@ -18,8 +19,8 @@ def _to_schema(i: IncidentModel) -> Incident:
severity=i.severity,
agent_id=i.agent_id,
check_id=i.check_id,
opened_at=i.opened_at,
resolved_at=i.resolved_at,
opened_at=to_utc(i.opened_at),
resolved_at=to_utc(i.resolved_at) if i.resolved_at else None,
summary=i.summary,
)
@@ -49,5 +50,5 @@ async def list_incidents(
next_cursor = None
if len(rows) > limit:
rows = rows[:limit]
next_cursor = encode(rows[-1].opened_at.isoformat(), rows[-1].id)
next_cursor = encode(to_utc(rows[-1].opened_at).isoformat(), rows[-1].id)
return IncidentsPage(items=[_to_schema(r) for r in rows], next_cursor=next_cursor)

View File

@@ -6,6 +6,7 @@ from ..cursors import decode, decode_datetime, encode
from ..db import get_session
from ..models import NotificationOutbox
from ..schemas import NotificationOutboxItem, OutboxPage
from ..timeutil import to_utc
router = APIRouter()
@@ -38,7 +39,7 @@ async def list_outbox(
next_cursor = None
if len(rows) > limit:
rows = rows[:limit]
next_cursor = encode(rows[-1].created_at.isoformat(), rows[-1].id)
next_cursor = encode(to_utc(rows[-1].created_at).isoformat(), rows[-1].id)
return OutboxPage(
items=[
NotificationOutboxItem(
@@ -48,9 +49,9 @@ async def list_outbox(
incident_id=str(r.incident_id),
event_type=r.event_type,
attempts=r.attempts,
next_attempt_at=r.next_attempt_at,
next_attempt_at=to_utc(r.next_attempt_at) if r.next_attempt_at else None,
last_error=r.last_error,
updated_at=r.updated_at,
updated_at=to_utc(r.updated_at),
)
for r in rows
],

View File

@@ -5,6 +5,8 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
from .timeutil import require_utc_datetime
ID_PATTERN = r"^[A-Za-z0-9._:-]+$"
UUIDV7_PATTERN = r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
ID_RE = re.compile(ID_PATTERN)
@@ -80,6 +82,11 @@ class HeartbeatRequest(BaseModel):
features: AgentFeatures
labels: dict[str, str] = Field(default_factory=dict)
@field_validator("observed_at")
@classmethod
def _observed_at_must_be_utc(cls, v: datetime) -> datetime:
return require_utc_datetime(v)
@field_validator("labels")
@classmethod
def _check_labels(cls, v: dict[str, str]) -> dict[str, str]:
@@ -109,6 +116,11 @@ class CheckResultEvent(BaseModel):
notifications_enabled: bool = True
incident_key: str | None = Field(default=None, max_length=256)
@field_validator("observed_at")
@classmethod
def _observed_at_must_be_utc(cls, v: datetime) -> datetime:
return require_utc_datetime(v)
@field_validator("output")
@classmethod
def _check_output_bytes(cls, v: str | None) -> str | None:

View File

@@ -12,6 +12,7 @@ from ..models import Agent, Check, Event, Incident, NotificationOutbox
from ..redaction import redact
from ..schemas import CheckResultEvent, HeartbeatRequest
from ..settings import get_settings
from ..timeutil import to_utc
INCIDENT_KEY_MAX = 256
@@ -37,11 +38,7 @@ def _incident_key(agent_id: str, ev: CheckResultEvent) -> str:
async def upsert_agent_heartbeat(session: AsyncSession, hb: HeartbeatRequest) -> None:
observed = (
hb.observed_at.astimezone(UTC)
if hb.observed_at.tzinfo
else hb.observed_at.replace(tzinfo=UTC)
)
observed = to_utc(hb.observed_at)
stmt = pg_insert(Agent).values(
agent_id=hb.agent_id,
hostname=hb.hostname,
@@ -71,7 +68,7 @@ async def _apply_incident(
incident_key: str,
) -> tuple[bool, Incident | None]:
"""Return (transition, incident). transition True if open->resolved or none->open."""
observed = ev.observed_at
observed = to_utc(ev.observed_at)
if ev.status == "ok":
res = await session.execute(
select(Incident).where(Incident.incident_key == incident_key, Incident.state == "open")
@@ -159,7 +156,7 @@ async def ingest_event(
ev: CheckResultEvent,
) -> bool:
"""Return True if accepted, False if deduplicated."""
observed = ev.observed_at
observed = to_utc(ev.observed_at)
output = redact(ev.output)
incident_key = _incident_key(agent_id, ev)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import httpx
from ...redaction import redact
from ...timeutil import payload_with_display_times
from .base import DeliveryResult
from .http import classify_response
@@ -10,11 +11,13 @@ from .http import classify_response
class AlertmanagerNotifier:
name = "alertmanager"
def __init__(self, client: httpx.AsyncClient, url: str) -> None:
def __init__(self, client: httpx.AsyncClient, url: str, time_zone: str) -> None:
self._client = client
self._url = url.rstrip("/")
self._time_zone = time_zone
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult:
display_payload = payload_with_display_times(payload, self._time_zone)
labels = {
"alertname": "monlet_incident",
"agent_id": str(payload.get("agent_id", "")),
@@ -25,7 +28,11 @@ class AlertmanagerNotifier:
"severity": str(payload.get("severity", "unknown")),
"status": str(payload.get("status", "")),
"summary": redact(payload.get("summary") or "") or "",
"time_zone": display_payload["time_zone"],
}
for key in ("observed_at_display", "opened_at_display", "resolved_at_display"):
if display_payload.get(key):
annotations[key.removesuffix("_display")] = display_payload[key]
if payload.get("output"):
annotations["output"] = (redact(payload["output"]) or "")[:1024]
alert = {

View File

@@ -1,14 +1,20 @@
from ...logging_config import get_logger
from ...redaction import redact_dict
from ...timeutil import payload_with_display_times
from .base import DeliveryResult
class DebugNotifier:
name = "debug"
def __init__(self) -> None:
def __init__(self, time_zone: str = "UTC") -> None:
self._log = get_logger("monlet.notifier.debug")
self._time_zone = time_zone
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult:
self._log.info("notify", event_type=event_type, payload=redact_dict(payload))
self._log.info(
"notify",
event_type=event_type,
payload=redact_dict(payload_with_display_times(payload, self._time_zone)),
)
return DeliveryResult(ok=True)

View File

@@ -13,19 +13,27 @@ from .webhook import WebhookNotifier
def build_registry(settings: Settings, client: httpx.AsyncClient) -> dict[str, Notifier]:
reg: dict[str, Notifier] = {}
if settings.notifier_debug_enabled:
reg["debug"] = DebugNotifier()
reg["debug"] = DebugNotifier(settings.notification_time_zone)
if (
settings.notifier_telegram_enabled
and settings.notifier_telegram_token
and settings.notifier_telegram_chat_id
):
reg["telegram"] = TelegramNotifier(
client, settings.notifier_telegram_token, settings.notifier_telegram_chat_id
client,
settings.notifier_telegram_token,
settings.notifier_telegram_chat_id,
settings.notification_time_zone,
)
if settings.notifier_webhook_enabled and settings.notifier_webhook_url:
reg["webhook"] = WebhookNotifier(
client, settings.notifier_webhook_url, settings.notifier_webhook_token
client,
settings.notifier_webhook_url,
settings.notifier_webhook_token,
settings.notification_time_zone,
)
if settings.notifier_alertmanager_enabled and settings.notifier_alertmanager_url:
reg["alertmanager"] = AlertmanagerNotifier(client, settings.notifier_alertmanager_url)
reg["alertmanager"] = AlertmanagerNotifier(
client, settings.notifier_alertmanager_url, settings.notification_time_zone
)
return reg

View File

@@ -3,11 +3,13 @@ from __future__ import annotations
import httpx
from ...redaction import redact
from ...timeutil import payload_with_display_times
from .base import DeliveryResult
from .http import classify_response
def _format_message(event_type: str, payload: dict) -> str:
def _format_message(event_type: str, payload: dict, time_zone: str) -> str:
payload = payload_with_display_times(payload, time_zone)
icon = "[FIRING]" if event_type == "firing" else "[RESOLVED]"
sev = payload.get("severity", "unknown")
agent = payload.get("agent_id", "?")
@@ -19,7 +21,7 @@ def _format_message(event_type: str, payload: dict) -> str:
f"{icon} {sev} {agent}/{check}",
f"status: {status}",
f"incident_key: {incident_key}",
f"observed_at: {payload.get('observed_at', '?')}",
f"observed_at: {payload.get('observed_at_display') or payload.get('observed_at', '?')}",
]
if output:
lines.append(f"output: {output[:300]}")
@@ -29,14 +31,18 @@ def _format_message(event_type: str, payload: dict) -> str:
class TelegramNotifier:
name = "telegram"
def __init__(self, client: httpx.AsyncClient, token: str, chat_id: str) -> None:
def __init__(self, client: httpx.AsyncClient, token: str, chat_id: str, time_zone: str) -> None:
self._client = client
self._token = token
self._chat_id = chat_id
self._time_zone = time_zone
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult:
url = f"https://api.telegram.org/bot{self._token}/sendMessage"
body = {"chat_id": self._chat_id, "text": _format_message(event_type, payload)}
body = {
"chat_id": self._chat_id,
"text": _format_message(event_type, payload, self._time_zone),
}
try:
resp = await self._client.post(url, json=body)
except httpx.HTTPError as exc:

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import httpx
from ...redaction import redact_dict
from ...timeutil import payload_with_display_times
from .base import DeliveryResult
from .http import classify_response
@@ -10,16 +11,22 @@ from .http import classify_response
class WebhookNotifier:
name = "webhook"
def __init__(self, client: httpx.AsyncClient, url: str, token: str = "") -> None:
def __init__(
self, client: httpx.AsyncClient, url: str, token: str = "", time_zone: str = "UTC"
) -> None:
self._client = client
self._url = url
self._token = token
self._time_zone = time_zone
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult:
headers = {"Content-Type": "application/json"}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
body = {"event_type": event_type, "incident": redact_dict(payload)}
body = {
"event_type": event_type,
"incident": redact_dict(payload_with_display_times(payload, self._time_zone)),
}
try:
resp = await self._client.post(self._url, json=body, headers=headers)
except httpx.HTTPError as exc:

View File

@@ -1,6 +1,8 @@
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from .timeutil import validate_time_zone
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="MONLET_", env_file=".env", extra="ignore")
@@ -25,6 +27,7 @@ class Settings(BaseSettings):
notifier_max_attempts: int = 8
notifier_lease_sec: int = 300
notifier_http_timeout_sec: float = 10.0
notification_time_zone: str = "UTC"
notifier_debug_enabled: bool = True
notifier_telegram_enabled: bool = False
@@ -43,6 +46,11 @@ class Settings(BaseSettings):
raise ValueError("MONLET_AUTH_TOKEN must be set to a non-default value")
return value
@field_validator("notification_time_zone")
@classmethod
def _validate_notification_time_zone(cls, value: str) -> str:
return validate_time_zone(value)
@property
def enabled_notifiers(self) -> list[str]:
out: list[str] = []

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
DEFAULT_TIME_ZONE = "UTC"
_TIMESTAMP_KEYS = ("observed_at", "opened_at", "resolved_at")
def validate_time_zone(value: str) -> str:
name = value.strip() or DEFAULT_TIME_ZONE
try:
ZoneInfo(name)
except ZoneInfoNotFoundError as exc:
raise ValueError(f"invalid timezone: {name}") from exc
return name
def require_utc_datetime(value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("timestamp must include UTC timezone")
if value.utcoffset() != timedelta(0):
raise ValueError("timestamp must be UTC")
return value.astimezone(UTC)
def to_utc(value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def format_display_datetime(value: str | datetime | None, time_zone: str) -> str:
if not value:
return ""
if isinstance(value, datetime):
dt = value
else:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return (
to_utc(dt)
.astimezone(ZoneInfo(validate_time_zone(time_zone)))
.strftime("%Y-%m-%d %H:%M:%S %Z")
)
def payload_with_display_times(payload: dict, time_zone: str) -> dict:
out = dict(payload)
out["time_zone"] = validate_time_zone(time_zone)
for key in _TIMESTAMP_KEYS:
if payload.get(key):
out[f"{key}_display"] = format_display_datetime(payload[key], out["time_zone"])
return out