From 33e544eb6d796a5373d243a185a9fbce5f51d47a Mon Sep 17 00:00:00 2001 From: aladdin Date: Thu, 23 Jul 2026 10:10:41 +0900 Subject: [PATCH 1/3] fix: interpret naive detected_at as UTC when rendering alert timestamps detected_at values loaded from the alerts table are naive datetimes stored in UTC. Passing them directly to datetime.astimezone() interprets them as local time, so on non-UTC hosts the alert "Time:" field showed the UTC value labeled with the local timezone, inconsistent with max_loaded_at in the same message which is correctly converted via convert_datetime_utc_str_to_timezone_str(). Attach UTC tzinfo to naive detected_at before converting; detected_at_utc is now always timezone-aware UTC. Timezone-aware inputs behave as before. Fixes #2304 Co-Authored-By: Claude Fable 5 --- elementary/monitor/alerts/alert.py | 6 +- .../unit/monitor/alerts/test_alert_models.py | 85 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) 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/tests/unit/monitor/alerts/test_alert_models.py b/tests/unit/monitor/alerts/test_alert_models.py index 13c5c5673..79de2dfa6 100644 --- a/tests/unit/monitor/alerts/test_alert_models.py +++ b/tests/unit/monitor/alerts/test_alert_models.py @@ -1,4 +1,8 @@ +import time +from datetime import datetime + import pytest +from dateutil import tz from elementary.monitor.alerts.alert import AlertModel from elementary.monitor.alerts.model_alert import ModelAlertModel @@ -6,6 +10,15 @@ 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" ) -> TestAlertModel: @@ -95,3 +108,75 @@ 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)." + ) From 8cdfbd081ca52e17355b61911db3fccd16ff3c6a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:20:29 +0000 Subject: [PATCH 2/3] fix: use tz-aware fallback in BaseAlertsGroup.detected_at for alerts with missing timestamps Co-Authored-By: Itamar Hartstein --- .../alerts/alerts_groups/base_alerts_group.py | 7 ++++++- tests/unit/monitor/alerts/test_alert_models.py | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) 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/tests/unit/monitor/alerts/test_alert_models.py b/tests/unit/monitor/alerts/test_alert_models.py index 79de2dfa6..d00bdd0a5 100644 --- a/tests/unit/monitor/alerts/test_alert_models.py +++ b/tests/unit/monitor/alerts/test_alert_models.py @@ -1,10 +1,12 @@ 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 @@ -20,7 +22,9 @@ def tokyo_local_timezone(monkeypatch): 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", @@ -32,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, ) @@ -180,3 +185,11 @@ def test_source_freshness_result_description_uses_consistent_timezone( "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 From e85b8cbb6838ba37546e2f469b8cc827127bb921 Mon Sep 17 00:00:00 2001 From: Itamar Hartstein Date: Tue, 8 Sep 2026 15:52:58 +0300 Subject: [PATCH 3/3] bugfix to _normalize_timezone_offset (fixes a broken test --- elementary/utils/time.py | 3 +++ 1 file changed, 3 insertions(+) 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)