Skip to content
Open
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 on lines +61 to 67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid mixing normalized timestamps with the group’s naive fallback.

After this change, a naive input produces an aware self.detected_at, but base_alerts_group.py:22-23 still evaluates min(alert.detected_at or datetime.max ...). A group containing one normalized alert and one missing timestamp now raises TypeError: can't compare offset-naive and offset-aware datetimes. Make that fallback timezone-aware (or exclude missing timestamps before min()) and add a mixed-group regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@elementary/monitor/alerts/alert.py` around lines 61 - 67, Update the group
timestamp calculation in the logic containing min(alert.detected_at or
datetime.max ...) so missing timestamps use a timezone-aware UTC fallback, or
are excluded before computing min, preventing comparisons between naive and
aware datetimes. Preserve the existing behavior for present timestamps and add a
regression test covering a group with one normalized alert and one missing
timestamp.

)
Expand Down
85 changes: 85 additions & 0 deletions tests/unit/monitor/alerts/test_alert_models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
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
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"
) -> TestAlertModel:
Expand Down Expand Up @@ -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)."
)
Loading