Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion elementary/monitor/alerts/alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ def __init__(
self.detected_at = None
if detected_at is not None:
try:
self.detected_at_utc = detected_at
# detected_at is stored in UTC, so a naive datetime must be
# interpreted as UTC rather than as the machine's local time.
if detected_at.tzinfo is None:
detected_at = detected_at.replace(tzinfo=tz.tzutc())
self.detected_at_utc = detected_at.astimezone(tz.tzutc())
self.detected_at = detected_at.astimezone(
tz.gettz(timezone) if timezone else tz.tzlocal()
Comment thread
haritamar marked this conversation as resolved.
)
Expand Down
7 changes: 6 additions & 1 deletion elementary/monitor/alerts/alerts_groups/base_alerts_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from datetime import datetime
from typing import Dict, List, Optional, Sequence

from dateutil import tz

from elementary.monitor.alerts.alert import AlertModel


Expand All @@ -20,7 +22,10 @@ def summary(self) -> str:

@property
def detected_at(self) -> datetime:
return min(alert.detected_at or datetime.max for alert in self.alerts)
return min(
alert.detected_at or datetime.max.replace(tzinfo=tz.tzutc())
for alert in self.alerts
)

@property
@abstractmethod
Expand Down
3 changes: 3 additions & 0 deletions elementary/utils/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ def datetime_strftime(datetime: datetime, include_timezone: bool = False) -> str


def _normalize_timezone_offset(time_string: str) -> str:
# Python < 3.11 fromisoformat() rejects a trailing "Z"; spell it out as an offset.
if time_string.endswith("Z"):
time_string = time_string[:-1] + "+00:00"
return _ABBREVIATED_TZ_OFFSET_PATTERN.sub(r"\1\2\3:00", time_string)


Expand Down
100 changes: 99 additions & 1 deletion tests/unit/monitor/alerts/test_alert_models.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import time
from datetime import datetime
from typing import Optional

import pytest
from dateutil import tz

from elementary.monitor.alerts.alert import AlertModel
from elementary.monitor.alerts.alerts_groups import AlertsGroup
from elementary.monitor.alerts.model_alert import ModelAlertModel
from elementary.monitor.alerts.source_freshness_alert import SourceFreshnessAlertModel
from elementary.monitor.alerts.test_alert import TestAlertModel


@pytest.fixture
def tokyo_local_timezone(monkeypatch):
monkeypatch.setenv("TZ", "Asia/Tokyo")
time.tzset()
yield
monkeypatch.undo()
time.tzset()


def _make_test_alert(
test_sub_type: str = "generic", test_short_name: str = "my_test"
test_sub_type: str = "generic",
test_short_name: str = "my_test",
detected_at: Optional[datetime] = None,
) -> TestAlertModel:
return TestAlertModel(
id="id",
Expand All @@ -19,6 +36,7 @@ def _make_test_alert(
test_sub_type=test_sub_type,
test_short_name=test_short_name,
alert_class_id="acid",
detected_at=detected_at,
)


Expand Down Expand Up @@ -95,3 +113,83 @@ def test_asset_type_is_source(self) -> None:
def test_concise_name_is_source_dot_identifier(self) -> None:
alert = _make_source_freshness_alert(source_name="raw", identifier="orders")
assert alert.concise_name == "raw.orders"


class TestAlertModelDetectedAtTimezone:
"""detected_at is stored in UTC (naive) in the alerts table and must be
interpreted as UTC, not as the machine's local time."""

def test_naive_detected_at_is_converted_to_given_timezone(
self, tokyo_local_timezone
) -> None:
alert = AlertModel(
id="id",
alert_class_id="acid",
detected_at=datetime(2026, 7, 22, 9, 8, 22),
timezone="Asia/Tokyo",
)
assert alert.detected_at == datetime(
2026, 7, 22, 18, 8, 22, tzinfo=tz.gettz("Asia/Tokyo")
)
assert alert.detected_at_str == "2026-07-22 18:08:22 JST"

def test_naive_detected_at_is_converted_to_local_timezone_by_default(
self, tokyo_local_timezone
) -> None:
alert = AlertModel(
id="id",
alert_class_id="acid",
detected_at=datetime(2026, 7, 22, 9, 8, 22),
)
assert alert.detected_at_str == "2026-07-22 18:08:22 JST"

def test_naive_detected_at_utc_is_utc_aware(self, tokyo_local_timezone) -> None:
alert = AlertModel(
id="id",
alert_class_id="acid",
detected_at=datetime(2026, 7, 22, 9, 8, 22),
timezone="Asia/Tokyo",
)
assert alert.detected_at_utc == datetime(
2026, 7, 22, 9, 8, 22, tzinfo=tz.tzutc()
)

def test_aware_detected_at_keeps_working(self, tokyo_local_timezone) -> None:
alert = AlertModel(
id="id",
alert_class_id="acid",
detected_at=datetime(2026, 7, 22, 9, 8, 22, tzinfo=tz.tzutc()),
timezone="Asia/Tokyo",
)
assert alert.detected_at_str == "2026-07-22 18:08:22 JST"

def test_source_freshness_result_description_uses_consistent_timezone(
self, tokyo_local_timezone
) -> None:
alert = SourceFreshnessAlertModel(
id="id",
source_name="my_source",
identifier="my_table",
original_status="fail",
path="models/src.yml",
error=None,
alert_class_id="acid",
source_freshness_execution_id="sfeid",
detected_at=datetime(2026, 7, 22, 9, 8, 22),
max_loaded_at=datetime(2026, 7, 21, 8, 55, 10),
max_loaded_at_time_ago_in_s=87101.8,
timezone="Asia/Tokyo",
)
assert alert.result_description == (
"When the test ran at 2026-07-22 18:08:22 JST, "
"the most recent record found in the table was 1 day 0h 11m 41s earlier "
"(2026-07-21 17:55:10 JST)."
)

def test_alerts_group_detected_at_with_missing_timestamp(
self, tokyo_local_timezone
) -> None:
with_time = _make_test_alert(detected_at=datetime(2026, 7, 22, 9, 8, 22))
without_time = _make_test_alert()
group = AlertsGroup(alerts=[with_time, without_time])
assert group.detected_at == with_time.detected_at
Loading