diff --git a/elementary/monitor/alerts/alert.py b/elementary/monitor/alerts/alert.py index eb1af224f..a31dd7a33 100644 --- a/elementary/monitor/alerts/alert.py +++ b/elementary/monitor/alerts/alert.py @@ -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() ) diff --git a/elementary/monitor/alerts/alerts_groups/base_alerts_group.py b/elementary/monitor/alerts/alerts_groups/base_alerts_group.py index 513ccfd00..3bdfb29f9 100644 --- a/elementary/monitor/alerts/alerts_groups/base_alerts_group.py +++ b/elementary/monitor/alerts/alerts_groups/base_alerts_group.py @@ -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 @@ -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 diff --git a/elementary/utils/time.py b/elementary/utils/time.py index f2f1b7007..c4c388836 100644 --- a/elementary/utils/time.py +++ b/elementary/utils/time.py @@ -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) diff --git a/tests/unit/monitor/alerts/test_alert_models.py b/tests/unit/monitor/alerts/test_alert_models.py index 13c5c5673..d00bdd0a5 100644 --- a/tests/unit/monitor/alerts/test_alert_models.py +++ b/tests/unit/monitor/alerts/test_alert_models.py @@ -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", @@ -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, ) @@ -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