From eb4cb4992ce030a83e77a853cf34a26f404cb890 Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Thu, 6 Jun 2024 18:29:46 +0100
Subject: [PATCH 01/20] init
---
.../src/scenes/insights/InsightPageHeader.tsx | 6 ++---
.../src/scenes/insights/insightSceneLogic.tsx | 26 +++++++------------
frontend/src/scenes/scenes.ts | 4 +--
3 files changed, 14 insertions(+), 22 deletions(-)
diff --git a/frontend/src/scenes/insights/InsightPageHeader.tsx b/frontend/src/scenes/insights/InsightPageHeader.tsx
index f7bee0b63a58e..4cd44031ad169 100644
--- a/frontend/src/scenes/insights/InsightPageHeader.tsx
+++ b/frontend/src/scenes/insights/InsightPageHeader.tsx
@@ -33,7 +33,7 @@ import { ExporterFormat, InsightLogicProps, InsightModel, InsightShortId, ItemMo
export function InsightPageHeader({ insightLogicProps }: { insightLogicProps: InsightLogicProps }): JSX.Element {
// insightSceneLogic
- const { insightMode, subscriptionId } = useValues(insightSceneLogic)
+ const { insightMode, itemId } = useValues(insightSceneLogic)
const { setInsightMode } = useActions(insightSceneLogic)
// insightLogic
@@ -65,7 +65,7 @@ export function InsightPageHeader({ insightLogicProps }: { insightLogicProps: In
isOpen={insightMode === ItemMode.Subscriptions}
closeModal={() => push(urls.insightView(insight.short_id as InsightShortId))}
insightShortId={insight.short_id}
- subscriptionId={subscriptionId}
+ subscriptionId={itemId}
/>
push(urls.insightView(insight.short_id as InsightShortId))}
insightShortId={insight.short_id as InsightShortId}
- alertId={subscriptionId}
+ alertId={itemId}
/>
>
diff --git a/frontend/src/scenes/insights/insightSceneLogic.tsx b/frontend/src/scenes/insights/insightSceneLogic.tsx
index 31be3b9c1df5a..af7bd39b69e13 100644
--- a/frontend/src/scenes/insights/insightSceneLogic.tsx
+++ b/frontend/src/scenes/insights/insightSceneLogic.tsx
@@ -32,10 +32,10 @@ export const insightSceneLogic = kea([
actions({
setInsightId: (insightId: InsightShortId) => ({ insightId }),
setInsightMode: (insightMode: ItemMode, source: InsightEventSource | null) => ({ insightMode, source }),
- setSceneState: (insightId: InsightShortId, insightMode: ItemMode, subscriptionId: string | undefined) => ({
+ setSceneState: (insightId: InsightShortId, insightMode: ItemMode, itemId: string | undefined) => ({
insightId,
insightMode,
- subscriptionId,
+ itemId,
}),
setInsightLogicRef: (logic: BuiltLogic | null, unmount: null | (() => void)) => ({
logic,
@@ -59,15 +59,11 @@ export const insightSceneLogic = kea([
setSceneState: (_, { insightMode }) => insightMode,
},
],
- subscriptionId: [
+ itemId: [
null as null | number | 'new',
{
- setSceneState: (_, { subscriptionId }) =>
- subscriptionId !== undefined
- ? subscriptionId === 'new'
- ? 'new'
- : parseInt(subscriptionId, 10)
- : null,
+ setSceneState: (_, { itemId }) =>
+ itemId !== undefined ? (itemId === 'new' ? 'new' : parseInt(itemId, 10)) : null,
},
],
insightLogicRef: [
@@ -174,8 +170,8 @@ export const insightSceneLogic = kea([
setSceneState: sharedListeners.reloadInsightLogic,
})),
urlToAction(({ actions, values }) => ({
- '/insights/:shortId(/:mode)(/:subscriptionId)': (
- { shortId, mode, subscriptionId }, // url params
+ '/insights/:shortId(/:mode)(/:itemId)': (
+ { shortId, mode, itemId }, // url params
{ dashboard, ...searchParams }, // search params
{ filters: _filters, q }, // hash params
{ method, initial }, // "location changed" event payload
@@ -209,12 +205,8 @@ export const insightSceneLogic = kea([
return
}
- if (
- insightId !== values.insightId ||
- insightMode !== values.insightMode ||
- subscriptionId !== values.subscriptionId
- ) {
- actions.setSceneState(insightId, insightMode, subscriptionId)
+ if (insightId !== values.insightId || insightMode !== values.insightMode || itemId !== values.itemId) {
+ actions.setSceneState(insightId, insightMode, itemId)
}
// capture any filters from the URL, either #filters={} or ?insight=X&bla=foo&bar=baz
diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts
index 94983524158f6..f6b0ca72ec587 100644
--- a/frontend/src/scenes/scenes.ts
+++ b/frontend/src/scenes/scenes.ts
@@ -494,8 +494,8 @@ export const routes: Record = {
[urls.insightEdit(':shortId' as InsightShortId)]: Scene.Insight,
[urls.insightView(':shortId' as InsightShortId)]: Scene.Insight,
[urls.insightSubcriptions(':shortId' as InsightShortId)]: Scene.Insight,
- [urls.insightSubcription(':shortId' as InsightShortId, ':subscriptionId')]: Scene.Insight,
- [urls.alert(':shortId' as InsightShortId, ':subscriptionId')]: Scene.Insight,
+ [urls.insightSubcription(':shortId' as InsightShortId, ':itemId')]: Scene.Insight,
+ [urls.alert(':shortId' as InsightShortId, ':itemId')]: Scene.Insight,
[urls.alerts(':shortId' as InsightShortId)]: Scene.Insight,
[urls.insightSharing(':shortId' as InsightShortId)]: Scene.Insight,
[urls.savedInsights()]: Scene.SavedInsights,
From ba51409809bc45f186f3e816a66692d4dd583681 Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Sat, 8 Jun 2024 16:46:05 +0100
Subject: [PATCH 02/20] initial version of the regular job
---
.../test/subscriptions/test_subscriptions.py | 2 -
.../lib/components/Alerts/views/EditAlert.tsx | 12 +-
.../insights/InsightNav/insightNavLogic.tsx | 2 +-
posthog/models/__init__.py | 4 +-
posthog/models/alert.py | 17 +-
posthog/tasks/__init__.py | 2 +
posthog/tasks/detect_alerts_anomalies.py | 73 +++++++++
posthog/tasks/scheduled.py | 7 +
posthog/tasks/tasks.py | 7 +
.../test/test_detect_alerts_anomalies.py | 150 ++++++++++++++++++
posthog/templates/email/alert_anomaly.html | 10 ++
11 files changed, 279 insertions(+), 7 deletions(-)
create mode 100644 posthog/tasks/detect_alerts_anomalies.py
create mode 100644 posthog/tasks/test/test_detect_alerts_anomalies.py
create mode 100644 posthog/templates/email/alert_anomaly.html
diff --git a/ee/tasks/test/subscriptions/test_subscriptions.py b/ee/tasks/test/subscriptions/test_subscriptions.py
index 717ab7b42d592..5f6db8011b231 100644
--- a/ee/tasks/test/subscriptions/test_subscriptions.py
+++ b/ee/tasks/test/subscriptions/test_subscriptions.py
@@ -15,7 +15,6 @@
from posthog.models.exported_asset import ExportedAsset
from posthog.models.insight import Insight
from posthog.models.instance_setting import set_instance_setting
-from posthog.models.subscription import Subscription
from posthog.test.base import APIBaseTest
@@ -24,7 +23,6 @@
@patch("ee.tasks.subscriptions.generate_assets")
@freeze_time("2022-02-02T08:55:00.000Z")
class TestSubscriptionsTasks(APIBaseTest):
- subscriptions: list[Subscription] = None # type: ignore
dashboard: Dashboard
insight: Insight
tiles: list[DashboardTile] = None # type: ignore
diff --git a/frontend/src/lib/components/Alerts/views/EditAlert.tsx b/frontend/src/lib/components/Alerts/views/EditAlert.tsx
index 6b60fcb84b778..8c2e42a8d5893 100644
--- a/frontend/src/lib/components/Alerts/views/EditAlert.tsx
+++ b/frontend/src/lib/components/Alerts/views/EditAlert.tsx
@@ -70,10 +70,18 @@ export function EditAlert({ id, insightShortId, onCancel, onDelete }: EditAlertP
-
+
-
+
diff --git a/frontend/src/scenes/insights/InsightNav/insightNavLogic.tsx b/frontend/src/scenes/insights/InsightNav/insightNavLogic.tsx
index b17a01d7c4d83..e4421f5778168 100644
--- a/frontend/src/scenes/insights/InsightNav/insightNavLogic.tsx
+++ b/frontend/src/scenes/insights/InsightNav/insightNavLogic.tsx
@@ -288,7 +288,7 @@ export const insightNavLogic = kea([
},
})),
urlToAction(({ actions }) => ({
- '/insights/:shortId(/:mode)(/:subscriptionId)': (
+ '/insights/:shortId(/:mode)(/:itemId)': (
_, // url params
{ dashboard, ...searchParams }, // search params
{ filters: _filters } // hash params
diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py
index d0f9dcf893c80..b4d0df1bda701 100644
--- a/posthog/models/__init__.py
+++ b/posthog/models/__init__.py
@@ -13,7 +13,7 @@
)
from ..warehouse.models import DataWarehouseTable
from ._deprecated_prompts import Prompt, PromptSequence, UserPromptState
-from .alert import Alert
+from .alert import Alert, AbsoluteThreshold, AnomalyCondition
from .action import Action
from .action.action_step import ActionStep
from .activity_logging.activity_log import ActivityLog
@@ -72,11 +72,13 @@
from .user_scene_personalisation import UserScenePersonalisation
__all__ = [
+ "AbsoluteThreshold",
"Alert",
"Action",
"ActionStep",
"ActivityLog",
"Annotation",
+ "AnomalyCondition",
"AsyncDeletion",
"AsyncMigration",
"AsyncMigrationError",
diff --git a/posthog/models/alert.py b/posthog/models/alert.py
index 5b8d9616bb06c..3ef6ee448499a 100644
--- a/posthog/models/alert.py
+++ b/posthog/models/alert.py
@@ -1,9 +1,24 @@
from django.db import models
+from typing import Optional
+from dataclasses import dataclass
+
+
+@dataclass
+class AbsoluteThreshold:
+ lower: Optional[float] = None
+ upper: Optional[float] = None
+
+
+class AnomalyCondition:
+ absolute_threshold: AbsoluteThreshold
+
+ def __init__(self, absoluteThreshold: dict):
+ self.absolute_threshold = AbsoluteThreshold(**absoluteThreshold)
class Alert(models.Model):
team: models.ForeignKey = models.ForeignKey("Team", on_delete=models.CASCADE)
- insight = models.ForeignKey("posthog.Insight", on_delete=models.CASCADE)
+ insight: models.ForeignKey = models.ForeignKey("posthog.Insight", on_delete=models.CASCADE)
name: models.CharField = models.CharField(max_length=100)
target_value: models.TextField = models.TextField()
diff --git a/posthog/tasks/__init__.py b/posthog/tasks/__init__.py
index 5298d6bd84409..545846c8a844c 100644
--- a/posthog/tasks/__init__.py
+++ b/posthog/tasks/__init__.py
@@ -6,6 +6,7 @@
check_clickhouse_schema_drift,
demo_create_data,
demo_reset_master_team,
+ detect_alerts_anomalies,
email,
exporter,
process_scheduled_changes,
@@ -24,6 +25,7 @@
"check_clickhouse_schema_drift",
"demo_create_data",
"demo_reset_master_team",
+ "detect_alerts_anomalies",
"email",
"exporter",
"process_scheduled_changes",
diff --git a/posthog/tasks/detect_alerts_anomalies.py b/posthog/tasks/detect_alerts_anomalies.py
new file mode 100644
index 0000000000000..f357a41ac0d97
--- /dev/null
+++ b/posthog/tasks/detect_alerts_anomalies.py
@@ -0,0 +1,73 @@
+import structlog
+from celery import shared_task
+from typing import cast
+from posthog.schema import HogQLQueryResponse
+from posthog.models import Alert, AnomalyCondition
+from posthog.hogql_queries.query_runner import get_query_runner
+from posthog.email import EmailMessage
+from django.utils import timezone
+from posthog.hogql_queries.legacy_compatibility.filter_to_query import filter_to_query
+
+logger = structlog.get_logger(__name__)
+
+
+def check_all_alerts() -> None:
+ alerts = Alert.objects.all().only("id")
+ for alert in alerts:
+ logger.info("scheduling alert", alert_id=alert.id)
+ check_alert.delay(alert.id)
+
+
+@shared_task(ignore_result=True)
+def check_alert(id: int) -> None:
+ alert = Alert.objects.get(pk=id)
+ insight = alert.insight
+ if not insight.query:
+ insight.query = filter_to_query(insight.filters)
+ query_runner = get_query_runner(insight.query, alert.team)
+ response = cast(HogQLQueryResponse, query_runner.calculate())
+ if not response.results:
+ raise RuntimeError(f"no results for alert {alert.id}")
+
+ anomaly_condition = AnomalyCondition(**alert.anomaly_condition)
+ thresholds = anomaly_condition.absolute_threshold
+
+ result = response.results[0]
+ aggregated_value = result["aggregated_value"]
+ anomalies_descriptions = []
+
+ if thresholds.lower is not None and aggregated_value < thresholds.lower:
+ anomalies_descriptions += [
+ f"The trend value ({aggregated_value}) is below the lower threshold ({thresholds.lower})"
+ ]
+ if thresholds.upper is not None and aggregated_value > thresholds.upper:
+ anomalies_descriptions += [
+ f"The trend value ({aggregated_value}) is above the upper threshold ({thresholds.upper})"
+ ]
+
+ if not anomalies_descriptions:
+ logger.info("no anomalies", alert_id=alert.id)
+ return
+
+ subject = f"PostHog alert {alert.name}"
+ campaign_key = f"alert-anomaly-notification-{alert.id}-{timezone.now().timestamp()}"
+ insight_url = f"/project/{alert.team.pk}/insights/{alert.insight.short_id}"
+ alert_url = f"{insight_url}/alerts/{alert.id}"
+ message = EmailMessage(
+ campaign_key=campaign_key,
+ subject=subject,
+ template_name="alert_anomaly",
+ template_context={
+ "anomalies_descriptions": anomalies_descriptions,
+ "insight_url": insight_url,
+ "insight_name": alert.insight.name,
+ "alert_url": alert_url,
+ "alert_name": alert.name,
+ },
+ )
+ targets = list(filter(len, alert.target_value.split(",")))
+ if not targets:
+ raise RuntimeError(f"no targets configured for alert {alert.id}")
+ for target in targets:
+ message.add_recipient(email=target)
+ message.send()
diff --git a/posthog/tasks/scheduled.py b/posthog/tasks/scheduled.py
index 87a0a0a1725c1..7264b02e505f9 100644
--- a/posthog/tasks/scheduled.py
+++ b/posthog/tasks/scheduled.py
@@ -25,6 +25,7 @@
clickhouse_row_count,
clickhouse_send_license_usage,
delete_expired_exported_assets,
+ detect_alerts_anomalies,
ee_persist_finished_recordings,
find_flags_with_enriched_analytics,
graphile_worker_queue_size,
@@ -239,6 +240,12 @@ def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None:
name="stop surveys that reached responses limits",
)
+ sender.add_periodic_task(
+ crontab(hour="*"),
+ detect_alerts_anomalies.s(),
+ name="detect alerts' anomalies and notify about them",
+ )
+
if settings.EE_AVAILABLE:
# every interval seconds, we calculate N replay embeddings
# the goal is to process _enough_ every 24 hours that
diff --git a/posthog/tasks/tasks.py b/posthog/tasks/tasks.py
index 35023a0387a40..5de22a80b17b4 100644
--- a/posthog/tasks/tasks.py
+++ b/posthog/tasks/tasks.py
@@ -714,6 +714,13 @@ def stop_surveys_reached_target() -> None:
stop_surveys_reached_target()
+@shared_task(ignore_result=True)
+def detect_alerts_anomalies() -> None:
+ from posthog.tasks.detect_alerts_anomalies import check_all_alerts
+
+ check_all_alerts()
+
+
def recompute_materialized_columns_enabled() -> bool:
from posthog.models.instance_setting import get_instance_setting
diff --git a/posthog/tasks/test/test_detect_alerts_anomalies.py b/posthog/tasks/test/test_detect_alerts_anomalies.py
new file mode 100644
index 0000000000000..00fbc6023962c
--- /dev/null
+++ b/posthog/tasks/test/test_detect_alerts_anomalies.py
@@ -0,0 +1,150 @@
+from unittest.mock import MagicMock, patch
+import pytest
+from typing import Optional
+from freezegun import freeze_time
+from posthog.models.instance_setting import set_instance_setting
+from posthog.test.base import APIBaseTest, _create_event, flush_persons_and_events, ClickhouseDestroyTablesMixin
+from posthog.api.test.dashboards import DashboardAPI
+from posthog.schema import ChartDisplayType, EventsNode, TrendsQuery, TrendsFilter
+from posthog.tasks.test.utils_email_tests import mock_email_messages
+from posthog.tasks.detect_alerts_anomalies import check_all_alerts
+
+
+@freeze_time("2024-06-02T08:55:00.000Z")
+@patch("posthog.tasks.detect_alerts_anomalies.EmailMessage")
+class TestDetectAlertsAnomaliesTasks(APIBaseTest, ClickhouseDestroyTablesMixin):
+ def setUp(self) -> None:
+ super().setUp()
+ set_instance_setting("EMAIL_HOST", "fake_host")
+ set_instance_setting("EMAIL_ENABLED", True)
+ self.settings(CELERY_TASK_ALWAYS_EAGER=True)
+ self.dashboard_api = DashboardAPI(self.client, self.team, self.assertEqual)
+ query_dict = TrendsQuery(
+ series=[
+ EventsNode(
+ event="$pageview",
+ ),
+ ],
+ trendsFilter=TrendsFilter(display=ChartDisplayType.BoldNumber),
+ ).model_dump()
+ self.insight = self.dashboard_api.create_insight(
+ data={
+ "name": "insight",
+ "query": query_dict,
+ }
+ )[1]
+
+ self.alert = self.client.post(
+ f"/api/projects/{self.team.id}/alerts",
+ data={
+ "name": "alert name",
+ "insight": self.insight["id"],
+ "target_value": "a@b.c,d@e.f",
+ "anomaly_condition": {"absoluteThreshold": {}},
+ },
+ ).json()
+
+ def set_thresholds(self, lower: Optional[int] = None, upper: Optional[int] = None):
+ self.client.patch(
+ f"/api/projects/{self.team.id}/alerts/{self.alert['id']}",
+ data={"anomaly_condition": {"absoluteThreshold": {"lower": lower, "upper": upper}}},
+ )
+
+ def test_alert_is_triggered_for_values_above_higher_threshold(self, MockEmailMessage: MagicMock):
+ mocked_email_messages = mock_email_messages(MockEmailMessage)
+ self.set_thresholds(upper=0)
+
+ with freeze_time("2024-06-02T07:55:00.000Z"):
+ _create_event(
+ team=self.team,
+ event="$pageview",
+ distinct_id="1",
+ )
+ flush_persons_and_events()
+
+ check_all_alerts()
+
+ assert len(mocked_email_messages) == 1
+ assert mocked_email_messages[0].to == [
+ {"recipient": "a@b.c", "raw_email": "a@b.c"},
+ {"recipient": "d@e.f", "raw_email": "d@e.f"},
+ ]
+ assert "The trend value (1) is above the upper threshold (0)" in mocked_email_messages[0].html_body
+
+ def test_alert_is_triggered_for_value_below_lower_threshold(self, MockEmailMessage: MagicMock):
+ mocked_email_messages = mock_email_messages(MockEmailMessage)
+ self.set_thresholds(lower=1)
+
+ check_all_alerts()
+
+ assert len(mocked_email_messages) == 1
+ assert "The trend value (0) is below the lower threshold (1)" in mocked_email_messages[0].html_body
+
+ def test_alert_is_not_triggered_for_normal_values(self, MockEmailMessage: MagicMock):
+ mocked_email_messages = mock_email_messages(MockEmailMessage)
+ self.set_thresholds(lower=0, upper=1)
+
+ check_all_alerts()
+
+ assert len(mocked_email_messages) == 0
+
+ def test_error_while_calculating_no_alert(self, MockEmailMessage: MagicMock):
+ mocked_email_messages = mock_email_messages(MockEmailMessage)
+ query_dict = TrendsQuery(
+ series=[
+ EventsNode(
+ event="$pageview",
+ ),
+ ],
+ ).model_dump()
+ insight = self.dashboard_api.create_insight(
+ data={
+ "name": "insight",
+ "query": query_dict,
+ }
+ )[1]
+
+ self.client.patch(f"/api/projects/{self.team.id}/alerts/{self.alert['id']}", data={"insight": insight["id"]})
+
+ # in production one alert failure won't cause an exception in check_all_alerts
+ # because execution won't be eager (see CELERY_TASK_ALWAYS_EAGER in the set up)
+ with pytest.raises(KeyError):
+ check_all_alerts()
+
+ assert len(mocked_email_messages) == 0
+
+ def test_two_alerts_are_triggered(self, MockEmailMessage: MagicMock):
+ mocked_email_messages = mock_email_messages(MockEmailMessage)
+ self.set_thresholds(lower=1)
+ self.client.post(
+ f"/api/projects/{self.team.id}/alerts",
+ data={
+ "name": "another alert name",
+ "insight": self.insight["id"],
+ "target_value": "email@address.com",
+ "anomaly_condition": {"absoluteThreshold": {"lower": 1}},
+ },
+ ).json()
+
+ check_all_alerts()
+
+ assert len(mocked_email_messages) == 2
+ assert "The trend value (0) is below the lower threshold (1)" in mocked_email_messages[0].html_body
+ assert "The trend value (0) is below the lower threshold (1)" in mocked_email_messages[1].html_body
+ recipients = [[to["recipient"] for to in message.to] for message in mocked_email_messages]
+ recipients = sorted(recipients)
+ assert recipients == [["a@b.c", "d@e.f"], ["email@address.com"]]
+
+ def test_alert_with_insight_with_filter(self, MockEmailMessage: MagicMock):
+ mocked_email_messages = mock_email_messages(MockEmailMessage)
+ insight = self.dashboard_api.create_insight(
+ data={"name": "insight", "filters": {"events": [{"id": "$pageview"}], "display": "BoldNumber"}}
+ )[1]
+
+ self.client.patch(f"/api/projects/{self.team.id}/alerts/{self.alert['id']}", data={"insight": insight["id"]})
+ self.set_thresholds(lower=1)
+
+ check_all_alerts()
+
+ assert len(mocked_email_messages) == 1
+ assert "The trend value (0) is below the lower threshold (1)" in mocked_email_messages[0].html_body
diff --git a/posthog/templates/email/alert_anomaly.html b/posthog/templates/email/alert_anomaly.html
new file mode 100644
index 0000000000000..90ab186edba67
--- /dev/null
+++ b/posthog/templates/email/alert_anomaly.html
@@ -0,0 +1,10 @@
+{% extends "email/base.html" %} {% load posthog_assets %} {% block section %}
+
+ The {{ alert_name }} alert detected following anomalies for {{ insight_name }}:
+
+ {% for anomaly_description in anomalies_descriptions %}
+ - {{ anomaly_description }}
+ {% endfor %}
+
+
+{% endblock %}{% load posthog_filters %}
\ No newline at end of file
From bb71f5aac855aa3d573b8348ab5829d514534f85 Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Sat, 8 Jun 2024 17:17:22 +0100
Subject: [PATCH 03/20] small polishing
---
posthog/tasks/detect_alerts_anomalies.py | 18 +++++++++++-------
.../tasks/test/test_detect_alerts_anomalies.py | 17 +++++++++--------
posthog/templates/email/alert_anomaly.html | 2 +-
3 files changed, 21 insertions(+), 16 deletions(-)
diff --git a/posthog/tasks/detect_alerts_anomalies.py b/posthog/tasks/detect_alerts_anomalies.py
index f357a41ac0d97..407759159ccd3 100644
--- a/posthog/tasks/detect_alerts_anomalies.py
+++ b/posthog/tasks/detect_alerts_anomalies.py
@@ -1,12 +1,14 @@
+from typing import cast
+
import structlog
from celery import shared_task
-from typing import cast
-from posthog.schema import HogQLQueryResponse
-from posthog.models import Alert, AnomalyCondition
-from posthog.hogql_queries.query_runner import get_query_runner
-from posthog.email import EmailMessage
from django.utils import timezone
+
+from posthog.email import EmailMessage
from posthog.hogql_queries.legacy_compatibility.filter_to_query import filter_to_query
+from posthog.hogql_queries.query_runner import get_query_runner
+from posthog.models import Alert, AnomalyCondition
+from posthog.schema import HogQLQueryResponse
logger = structlog.get_logger(__name__)
@@ -49,7 +51,7 @@ def check_alert(id: int) -> None:
logger.info("no anomalies", alert_id=alert.id)
return
- subject = f"PostHog alert {alert.name}"
+ subject = f"PostHog alert {alert.name} has anomalies"
campaign_key = f"alert-anomaly-notification-{alert.id}-{timezone.now().timestamp()}"
insight_url = f"/project/{alert.team.pk}/insights/{alert.insight.short_id}"
alert_url = f"{insight_url}/alerts/{alert.id}"
@@ -67,7 +69,9 @@ def check_alert(id: int) -> None:
)
targets = list(filter(len, alert.target_value.split(",")))
if not targets:
- raise RuntimeError(f"no targets configured for alert {alert.id}")
+ raise RuntimeError(f"no targets configured for the alert {alert.id}")
for target in targets:
message.add_recipient(email=target)
+
+ logger.info(f"Send notifications about {len(anomalies_descriptions)} anomalies", alert_id=alert.id)
message.send()
diff --git a/posthog/tasks/test/test_detect_alerts_anomalies.py b/posthog/tasks/test/test_detect_alerts_anomalies.py
index 00fbc6023962c..21551fa4e8b93 100644
--- a/posthog/tasks/test/test_detect_alerts_anomalies.py
+++ b/posthog/tasks/test/test_detect_alerts_anomalies.py
@@ -1,7 +1,9 @@
-from unittest.mock import MagicMock, patch
import pytest
from typing import Optional
+from unittest.mock import MagicMock, patch
+
from freezegun import freeze_time
+
from posthog.models.instance_setting import set_instance_setting
from posthog.test.base import APIBaseTest, _create_event, flush_persons_and_events, ClickhouseDestroyTablesMixin
from posthog.api.test.dashboards import DashboardAPI
@@ -50,6 +52,10 @@ def set_thresholds(self, lower: Optional[int] = None, upper: Optional[int] = Non
data={"anomaly_condition": {"absoluteThreshold": {"lower": lower, "upper": upper}}},
)
+ def get_recepients(self, mocked_email_messages) -> list[list[str]]:
+ recipients = [sorted([to["recipient"] for to in message.to]) for message in mocked_email_messages]
+ return sorted(recipients)
+
def test_alert_is_triggered_for_values_above_higher_threshold(self, MockEmailMessage: MagicMock):
mocked_email_messages = mock_email_messages(MockEmailMessage)
self.set_thresholds(upper=0)
@@ -65,10 +71,7 @@ def test_alert_is_triggered_for_values_above_higher_threshold(self, MockEmailMes
check_all_alerts()
assert len(mocked_email_messages) == 1
- assert mocked_email_messages[0].to == [
- {"recipient": "a@b.c", "raw_email": "a@b.c"},
- {"recipient": "d@e.f", "raw_email": "d@e.f"},
- ]
+ assert self.get_recepients(mocked_email_messages) == [["a@b.c", "d@e.f"]]
assert "The trend value (1) is above the upper threshold (0)" in mocked_email_messages[0].html_body
def test_alert_is_triggered_for_value_below_lower_threshold(self, MockEmailMessage: MagicMock):
@@ -131,9 +134,7 @@ def test_two_alerts_are_triggered(self, MockEmailMessage: MagicMock):
assert len(mocked_email_messages) == 2
assert "The trend value (0) is below the lower threshold (1)" in mocked_email_messages[0].html_body
assert "The trend value (0) is below the lower threshold (1)" in mocked_email_messages[1].html_body
- recipients = [[to["recipient"] for to in message.to] for message in mocked_email_messages]
- recipients = sorted(recipients)
- assert recipients == [["a@b.c", "d@e.f"], ["email@address.com"]]
+ assert self.get_recepients(mocked_email_messages) == [["a@b.c", "d@e.f"], ["email@address.com"]]
def test_alert_with_insight_with_filter(self, MockEmailMessage: MagicMock):
mocked_email_messages = mock_email_messages(MockEmailMessage)
diff --git a/posthog/templates/email/alert_anomaly.html b/posthog/templates/email/alert_anomaly.html
index 90ab186edba67..c4ab2cea13ace 100644
--- a/posthog/templates/email/alert_anomaly.html
+++ b/posthog/templates/email/alert_anomaly.html
@@ -1,6 +1,6 @@
{% extends "email/base.html" %} {% load posthog_assets %} {% block section %}
- The {{ alert_name }} alert detected following anomalies for {{ insight_name }}:
+ Uh-oh, the {{ alert_name }} alert detected following anomalies for {{ insight_name }}:
{% for anomaly_description in anomalies_descriptions %}
- {{ anomaly_description }}
From 15e0b6b536a3e85f1f468a95e2ea40815743463c Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Sat, 8 Jun 2024 17:24:58 +0100
Subject: [PATCH 04/20] small test fix
---
posthog/tasks/test/test_detect_alerts_anomalies.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/posthog/tasks/test/test_detect_alerts_anomalies.py b/posthog/tasks/test/test_detect_alerts_anomalies.py
index 21551fa4e8b93..54bc6bf58e455 100644
--- a/posthog/tasks/test/test_detect_alerts_anomalies.py
+++ b/posthog/tasks/test/test_detect_alerts_anomalies.py
@@ -109,8 +109,8 @@ def test_error_while_calculating_no_alert(self, MockEmailMessage: MagicMock):
self.client.patch(f"/api/projects/{self.team.id}/alerts/{self.alert['id']}", data={"insight": insight["id"]})
- # in production one alert failure won't cause an exception in check_all_alerts
- # because execution won't be eager (see CELERY_TASK_ALWAYS_EAGER in the set up)
+ # in production failure of a single alert won't cause an exception in check_all_alerts
+ # because the execution won't be eager (see CELERY_TASK_ALWAYS_EAGER in the set up)
with pytest.raises(KeyError):
check_all_alerts()
From 71c44d9084129f3303599faccb576c05a034a737 Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Sat, 8 Jun 2024 17:29:44 +0100
Subject: [PATCH 05/20] fix types in tests
---
.../tasks/test/test_detect_alerts_anomalies.py | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/posthog/tasks/test/test_detect_alerts_anomalies.py b/posthog/tasks/test/test_detect_alerts_anomalies.py
index 54bc6bf58e455..2f7e7e2b9924b 100644
--- a/posthog/tasks/test/test_detect_alerts_anomalies.py
+++ b/posthog/tasks/test/test_detect_alerts_anomalies.py
@@ -1,5 +1,5 @@
import pytest
-from typing import Optional
+from typing import Any, Optional
from unittest.mock import MagicMock, patch
from freezegun import freeze_time
@@ -46,17 +46,17 @@ def setUp(self) -> None:
},
).json()
- def set_thresholds(self, lower: Optional[int] = None, upper: Optional[int] = None):
+ def set_thresholds(self, lower: Optional[int] = None, upper: Optional[int] = None) -> None:
self.client.patch(
f"/api/projects/{self.team.id}/alerts/{self.alert['id']}",
data={"anomaly_condition": {"absoluteThreshold": {"lower": lower, "upper": upper}}},
)
- def get_recepients(self, mocked_email_messages) -> list[list[str]]:
+ def get_recepients(self, mocked_email_messages: list[Any]) -> list[list[str]]:
recipients = [sorted([to["recipient"] for to in message.to]) for message in mocked_email_messages]
return sorted(recipients)
- def test_alert_is_triggered_for_values_above_higher_threshold(self, MockEmailMessage: MagicMock):
+ def test_alert_is_triggered_for_values_above_higher_threshold(self, MockEmailMessage: MagicMock) -> None:
mocked_email_messages = mock_email_messages(MockEmailMessage)
self.set_thresholds(upper=0)
@@ -74,7 +74,7 @@ def test_alert_is_triggered_for_values_above_higher_threshold(self, MockEmailMes
assert self.get_recepients(mocked_email_messages) == [["a@b.c", "d@e.f"]]
assert "The trend value (1) is above the upper threshold (0)" in mocked_email_messages[0].html_body
- def test_alert_is_triggered_for_value_below_lower_threshold(self, MockEmailMessage: MagicMock):
+ def test_alert_is_triggered_for_value_below_lower_threshold(self, MockEmailMessage: MagicMock) -> None:
mocked_email_messages = mock_email_messages(MockEmailMessage)
self.set_thresholds(lower=1)
@@ -83,7 +83,7 @@ def test_alert_is_triggered_for_value_below_lower_threshold(self, MockEmailMessa
assert len(mocked_email_messages) == 1
assert "The trend value (0) is below the lower threshold (1)" in mocked_email_messages[0].html_body
- def test_alert_is_not_triggered_for_normal_values(self, MockEmailMessage: MagicMock):
+ def test_alert_is_not_triggered_for_normal_values(self, MockEmailMessage: MagicMock) -> None:
mocked_email_messages = mock_email_messages(MockEmailMessage)
self.set_thresholds(lower=0, upper=1)
@@ -91,7 +91,7 @@ def test_alert_is_not_triggered_for_normal_values(self, MockEmailMessage: MagicM
assert len(mocked_email_messages) == 0
- def test_error_while_calculating_no_alert(self, MockEmailMessage: MagicMock):
+ def test_error_while_calculating_no_alert(self, MockEmailMessage: MagicMock) -> None:
mocked_email_messages = mock_email_messages(MockEmailMessage)
query_dict = TrendsQuery(
series=[
@@ -116,7 +116,7 @@ def test_error_while_calculating_no_alert(self, MockEmailMessage: MagicMock):
assert len(mocked_email_messages) == 0
- def test_two_alerts_are_triggered(self, MockEmailMessage: MagicMock):
+ def test_two_alerts_are_triggered(self, MockEmailMessage: MagicMock) -> None:
mocked_email_messages = mock_email_messages(MockEmailMessage)
self.set_thresholds(lower=1)
self.client.post(
@@ -136,7 +136,7 @@ def test_two_alerts_are_triggered(self, MockEmailMessage: MagicMock):
assert "The trend value (0) is below the lower threshold (1)" in mocked_email_messages[1].html_body
assert self.get_recepients(mocked_email_messages) == [["a@b.c", "d@e.f"], ["email@address.com"]]
- def test_alert_with_insight_with_filter(self, MockEmailMessage: MagicMock):
+ def test_alert_with_insight_with_filter(self, MockEmailMessage: MagicMock) -> None:
mocked_email_messages = mock_email_messages(MockEmailMessage)
insight = self.dashboard_api.create_insight(
data={"name": "insight", "filters": {"events": [{"id": "$pageview"}], "display": "BoldNumber"}}
From 5e59a5d2dcb26d932453bed2a9e9210d42ba2321 Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Sat, 8 Jun 2024 17:34:52 +0100
Subject: [PATCH 06/20] fix the crontab schedule to every hour
---
posthog/tasks/scheduled.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/posthog/tasks/scheduled.py b/posthog/tasks/scheduled.py
index 5c4faf0ce680a..62482b40e8b6a 100644
--- a/posthog/tasks/scheduled.py
+++ b/posthog/tasks/scheduled.py
@@ -248,7 +248,7 @@ def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None:
)
sender.add_periodic_task(
- crontab(hour="*"),
+ crontab(hour="*", minute="30"),
detect_alerts_anomalies.s(),
name="detect alerts' anomalies and notify about them",
)
From 29e613b9243877db4e41c5f1997547dfc9cb1a84 Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Sat, 8 Jun 2024 17:45:53 +0100
Subject: [PATCH 07/20] add a newline to the template
---
posthog/tasks/scheduled.py | 2 +-
posthog/templates/email/alert_anomaly.html | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/posthog/tasks/scheduled.py b/posthog/tasks/scheduled.py
index 62482b40e8b6a..1c051739489c2 100644
--- a/posthog/tasks/scheduled.py
+++ b/posthog/tasks/scheduled.py
@@ -248,7 +248,7 @@ def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None:
)
sender.add_periodic_task(
- crontab(hour="*", minute="30"),
+ crontab(hour="*", minute="20"),
detect_alerts_anomalies.s(),
name="detect alerts' anomalies and notify about them",
)
diff --git a/posthog/templates/email/alert_anomaly.html b/posthog/templates/email/alert_anomaly.html
index c4ab2cea13ace..c50dafd157cc3 100644
--- a/posthog/templates/email/alert_anomaly.html
+++ b/posthog/templates/email/alert_anomaly.html
@@ -7,4 +7,4 @@
{% endfor %}
-{% endblock %}{% load posthog_filters %}
\ No newline at end of file
+{% endblock %}{% load posthog_filters %}
From b1a0219a6252d46055cc4e62b0599f95bb611498 Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Tue, 11 Jun 2024 17:52:31 +0100
Subject: [PATCH 08/20] add a test to check insight date range
---
.../tasks/test/test_detect_alerts_anomalies.py | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/posthog/tasks/test/test_detect_alerts_anomalies.py b/posthog/tasks/test/test_detect_alerts_anomalies.py
index 2f7e7e2b9924b..7caa22ba34e65 100644
--- a/posthog/tasks/test/test_detect_alerts_anomalies.py
+++ b/posthog/tasks/test/test_detect_alerts_anomalies.py
@@ -74,6 +74,22 @@ def test_alert_is_triggered_for_values_above_higher_threshold(self, MockEmailMes
assert self.get_recepients(mocked_email_messages) == [["a@b.c", "d@e.f"]]
assert "The trend value (1) is above the upper threshold (0)" in mocked_email_messages[0].html_body
+ def test_alert_is_not_triggered_for_events_beyond_interval(self, MockEmailMessage: MagicMock) -> None:
+ mocked_email_messages = mock_email_messages(MockEmailMessage)
+ self.set_thresholds(upper=0)
+
+ with freeze_time("2024-05-02T07:55:00.000Z"):
+ _create_event(
+ team=self.team,
+ event="$pageview",
+ distinct_id="1",
+ )
+ flush_persons_and_events()
+
+ check_all_alerts()
+
+ assert len(mocked_email_messages) == 0
+
def test_alert_is_triggered_for_value_below_lower_threshold(self, MockEmailMessage: MagicMock) -> None:
mocked_email_messages = mock_email_messages(MockEmailMessage)
self.set_thresholds(lower=1)
From 29d93054554da2e70ee02265f6200ba55d5ef44a Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Tue, 11 Jun 2024 18:57:28 +0100
Subject: [PATCH 09/20] use the new display type naming
---
posthog/tasks/test/test_detect_alerts_anomalies.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/posthog/tasks/test/test_detect_alerts_anomalies.py b/posthog/tasks/test/test_detect_alerts_anomalies.py
index 7caa22ba34e65..dddf97c2e8dd7 100644
--- a/posthog/tasks/test/test_detect_alerts_anomalies.py
+++ b/posthog/tasks/test/test_detect_alerts_anomalies.py
@@ -27,7 +27,7 @@ def setUp(self) -> None:
event="$pageview",
),
],
- trendsFilter=TrendsFilter(display=ChartDisplayType.BoldNumber),
+ trendsFilter=TrendsFilter(display=ChartDisplayType.BOLD_NUMBER),
).model_dump()
self.insight = self.dashboard_api.create_insight(
data={
From b8b4fec43a9bc2daa8e8bc3c4eed2594e59fe163 Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Tue, 2 Jul 2024 20:53:51 +0100
Subject: [PATCH 10/20] address PR comments
---
.../lib/components/Alerts/views/EditAlert.tsx | 2 +-
frontend/src/queries/schema.json | 23 ++++++++++
frontend/src/queries/schema.ts | 9 ++++
frontend/src/types.ts | 1 +
posthog/caching/calculate_results.py | 2 +-
posthog/models/__init__.py | 4 +-
posthog/models/alert.py | 15 -------
posthog/schema.py | 15 +++++++
posthog/tasks/detect_alerts_anomalies.py | 45 ++++++++++++-------
.../test/test_detect_alerts_anomalies.py | 10 ++---
10 files changed, 85 insertions(+), 41 deletions(-)
diff --git a/frontend/src/lib/components/Alerts/views/EditAlert.tsx b/frontend/src/lib/components/Alerts/views/EditAlert.tsx
index 8c2e42a8d5893..69d12f716623d 100644
--- a/frontend/src/lib/components/Alerts/views/EditAlert.tsx
+++ b/frontend/src/lib/components/Alerts/views/EditAlert.tsx
@@ -79,7 +79,7 @@ export function EditAlert({ id, insightShortId, onCancel, onDelete }: EditAlertP
diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json
index f00512ba8906e..ef3d2392ba347 100644
--- a/frontend/src/queries/schema.json
+++ b/frontend/src/queries/schema.json
@@ -1,6 +1,19 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
+ "AbsoluteThreshold": {
+ "additionalProperties": false,
+ "properties": {
+ "lower": {
+ "type": ["number", "null"]
+ },
+ "upper": {
+ "type": ["number", "null"]
+ }
+ },
+ "required": ["lower", "upper"],
+ "type": "object"
+ },
"ActionsNode": {
"additionalProperties": false,
"properties": {
@@ -179,6 +192,16 @@
"enum": ["numeric", "duration", "duration_ms", "percentage", "percentage_scaled"],
"type": "string"
},
+ "AnomalyCondition": {
+ "additionalProperties": false,
+ "properties": {
+ "absoluteThreshold": {
+ "$ref": "#/definitions/AbsoluteThreshold"
+ }
+ },
+ "required": ["absoluteThreshold"],
+ "type": "object"
+ },
"AnyDataNode": {
"anyOf": [
{
diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts
index e34100a494b75..c5874d82aa8ca 100644
--- a/frontend/src/queries/schema.ts
+++ b/frontend/src/queries/schema.ts
@@ -1586,3 +1586,12 @@ export interface DashboardFilter {
date_to?: string | null
properties?: AnyPropertyFilter[] | null
}
+
+export interface AbsoluteThreshold {
+ lower: number | null
+ upper: number | null
+}
+
+export interface AnomalyCondition {
+ absoluteThreshold: AbsoluteThreshold
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index fdf011a2e8e19..ef0ca12120951 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -4273,6 +4273,7 @@ export type HogFunctionStatus = {
}[]
}
+// TODO: move to schema.ts
export interface AnomalyCondition {
absoluteThreshold: {
lower?: number
diff --git a/posthog/caching/calculate_results.py b/posthog/caching/calculate_results.py
index ee21f46cc6a3d..5f7507c85c02e 100644
--- a/posthog/caching/calculate_results.py
+++ b/posthog/caching/calculate_results.py
@@ -123,7 +123,7 @@ def get_cache_type(cacheable: Optional[FilterType] | Optional[dict]) -> CacheTyp
def calculate_for_query_based_insight(
- insight: Insight, *, dashboard: Optional[Dashboard] = None, execution_mode: ExecutionMode, user: User
+ insight: Insight, *, dashboard: Optional[Dashboard] = None, execution_mode: ExecutionMode, user: Optional[User]
) -> "InsightResult":
from posthog.caching.fetch_from_cache import InsightResult, NothingInCacheResult
from posthog.caching.insight_cache import update_cached_state
diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py
index f2f36b0c7cb0e..389d573cb7e7b 100644
--- a/posthog/models/__init__.py
+++ b/posthog/models/__init__.py
@@ -13,7 +13,7 @@
)
from ..warehouse.models import DataWarehouseTable
from ._deprecated_prompts import Prompt, PromptSequence, UserPromptState
-from .alert import Alert, AbsoluteThreshold, AnomalyCondition
+from .alert import Alert
from .action import Action
from .action.action_step import ActionStep
from .activity_logging.activity_log import ActivityLog
@@ -73,13 +73,11 @@
from .user_scene_personalisation import UserScenePersonalisation
__all__ = [
- "AbsoluteThreshold",
"Alert",
"Action",
"ActionStep",
"ActivityLog",
"Annotation",
- "AnomalyCondition",
"AsyncDeletion",
"AsyncMigration",
"AsyncMigrationError",
diff --git a/posthog/models/alert.py b/posthog/models/alert.py
index 3ef6ee448499a..1c324a32798f6 100644
--- a/posthog/models/alert.py
+++ b/posthog/models/alert.py
@@ -1,19 +1,4 @@
from django.db import models
-from typing import Optional
-from dataclasses import dataclass
-
-
-@dataclass
-class AbsoluteThreshold:
- lower: Optional[float] = None
- upper: Optional[float] = None
-
-
-class AnomalyCondition:
- absolute_threshold: AbsoluteThreshold
-
- def __init__(self, absoluteThreshold: dict):
- self.absolute_threshold = AbsoluteThreshold(**absoluteThreshold)
class Alert(models.Model):
diff --git a/posthog/schema.py b/posthog/schema.py
index c46df6df52699..4040a98c04691 100644
--- a/posthog/schema.py
+++ b/posthog/schema.py
@@ -12,6 +12,14 @@ class SchemaRoot(RootModel[Any]):
root: Any
+class AbsoluteThreshold(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ lower: Optional[float] = None
+ upper: Optional[float] = None
+
+
class MathGroupTypeIndex(float, Enum):
NUMBER_0 = 0
NUMBER_1 = 1
@@ -28,6 +36,13 @@ class AggregationAxisFormat(StrEnum):
PERCENTAGE_SCALED = "percentage_scaled"
+class AnomalyCondition(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ absoluteThreshold: AbsoluteThreshold
+
+
class Kind(StrEnum):
METHOD = "Method"
FUNCTION = "Function"
diff --git a/posthog/tasks/detect_alerts_anomalies.py b/posthog/tasks/detect_alerts_anomalies.py
index 407759159ccd3..3351c6a172c25 100644
--- a/posthog/tasks/detect_alerts_anomalies.py
+++ b/posthog/tasks/detect_alerts_anomalies.py
@@ -1,14 +1,15 @@
-from typing import cast
-
import structlog
from celery import shared_task
from django.utils import timezone
+from posthog.api.services.query import ExecutionMode
+from posthog.caching.calculate_results import calculate_for_query_based_insight
from posthog.email import EmailMessage
-from posthog.hogql_queries.legacy_compatibility.filter_to_query import filter_to_query
-from posthog.hogql_queries.query_runner import get_query_runner
-from posthog.models import Alert, AnomalyCondition
-from posthog.schema import HogQLQueryResponse
+from posthog.hogql_queries.legacy_compatibility.flagged_conversion_manager import (
+ conversion_to_query_based,
+)
+from posthog.models import Alert
+from posthog.schema import AnomalyCondition
logger = structlog.get_logger(__name__)
@@ -17,24 +18,31 @@ def check_all_alerts() -> None:
alerts = Alert.objects.all().only("id")
for alert in alerts:
logger.info("scheduling alert", alert_id=alert.id)
- check_alert.delay(alert.id)
+ _check_alert_task.delay(alert.id)
@shared_task(ignore_result=True)
-def check_alert(id: int) -> None:
+def _check_alert_task(id: int) -> None:
+ _check_alert(id)
+
+
+def _check_alert(id: int) -> None:
alert = Alert.objects.get(pk=id)
insight = alert.insight
- if not insight.query:
- insight.query = filter_to_query(insight.filters)
- query_runner = get_query_runner(insight.query, alert.team)
- response = cast(HogQLQueryResponse, query_runner.calculate())
- if not response.results:
+ with conversion_to_query_based(insight):
+ calculation_result = calculate_for_query_based_insight(
+ insight,
+ execution_mode=ExecutionMode.RECENT_CACHE_CALCULATE_BLOCKING_IF_STALE,
+ user=None,
+ )
+
+ if not calculation_result.result:
raise RuntimeError(f"no results for alert {alert.id}")
- anomaly_condition = AnomalyCondition(**alert.anomaly_condition)
- thresholds = anomaly_condition.absolute_threshold
+ anomaly_condition = AnomalyCondition.model_validate(alert.anomaly_condition)
+ thresholds = anomaly_condition.absoluteThreshold
- result = response.results[0]
+ result = calculation_result.result[0]
aggregated_value = result["aggregated_value"]
anomalies_descriptions = []
@@ -51,6 +59,11 @@ def check_alert(id: int) -> None:
logger.info("no anomalies", alert_id=alert.id)
return
+ _send_notifications(alert, anomalies_descriptions)
+
+
+# TODO: make it a task
+def _send_notifications(alert: Alert, anomalies_descriptions: list[str]) -> None:
subject = f"PostHog alert {alert.name} has anomalies"
campaign_key = f"alert-anomaly-notification-{alert.id}-{timezone.now().timestamp()}"
insight_url = f"/project/{alert.team.pk}/insights/{alert.insight.short_id}"
diff --git a/posthog/tasks/test/test_detect_alerts_anomalies.py b/posthog/tasks/test/test_detect_alerts_anomalies.py
index dddf97c2e8dd7..47881d551d191 100644
--- a/posthog/tasks/test/test_detect_alerts_anomalies.py
+++ b/posthog/tasks/test/test_detect_alerts_anomalies.py
@@ -72,7 +72,7 @@ def test_alert_is_triggered_for_values_above_higher_threshold(self, MockEmailMes
assert len(mocked_email_messages) == 1
assert self.get_recepients(mocked_email_messages) == [["a@b.c", "d@e.f"]]
- assert "The trend value (1) is above the upper threshold (0)" in mocked_email_messages[0].html_body
+ assert "The trend value (1) is above the upper threshold (0.0)" in mocked_email_messages[0].html_body
def test_alert_is_not_triggered_for_events_beyond_interval(self, MockEmailMessage: MagicMock) -> None:
mocked_email_messages = mock_email_messages(MockEmailMessage)
@@ -97,7 +97,7 @@ def test_alert_is_triggered_for_value_below_lower_threshold(self, MockEmailMessa
check_all_alerts()
assert len(mocked_email_messages) == 1
- assert "The trend value (0) is below the lower threshold (1)" in mocked_email_messages[0].html_body
+ assert "The trend value (0) is below the lower threshold (1.0)" in mocked_email_messages[0].html_body
def test_alert_is_not_triggered_for_normal_values(self, MockEmailMessage: MagicMock) -> None:
mocked_email_messages = mock_email_messages(MockEmailMessage)
@@ -148,8 +148,8 @@ def test_two_alerts_are_triggered(self, MockEmailMessage: MagicMock) -> None:
check_all_alerts()
assert len(mocked_email_messages) == 2
- assert "The trend value (0) is below the lower threshold (1)" in mocked_email_messages[0].html_body
- assert "The trend value (0) is below the lower threshold (1)" in mocked_email_messages[1].html_body
+ assert "The trend value (0) is below the lower threshold (1.0)" in mocked_email_messages[0].html_body
+ assert "The trend value (0) is below the lower threshold (1.0)" in mocked_email_messages[1].html_body
assert self.get_recepients(mocked_email_messages) == [["a@b.c", "d@e.f"], ["email@address.com"]]
def test_alert_with_insight_with_filter(self, MockEmailMessage: MagicMock) -> None:
@@ -164,4 +164,4 @@ def test_alert_with_insight_with_filter(self, MockEmailMessage: MagicMock) -> No
check_all_alerts()
assert len(mocked_email_messages) == 1
- assert "The trend value (0) is below the lower threshold (1)" in mocked_email_messages[0].html_body
+ assert "The trend value (0) is below the lower threshold (1.0)" in mocked_email_messages[0].html_body
From 16227b9358cee004b31548c1f890d6a1c8ece60d Mon Sep 17 00:00:00 2001
From: Julian Bez
Date: Fri, 5 Jul 2024 15:10:35 +0200
Subject: [PATCH 11/20] Refactor things
---
posthog/tasks/__init__.py | 2 -
...ct_alerts_anomalies.py => check_alerts.py} | 48 ++++++++++++++-----
posthog/tasks/scheduled.py | 4 +-
posthog/tasks/tasks.py | 7 ---
...erts_anomalies.py => test_check_alerts.py} | 4 +-
5 files changed, 39 insertions(+), 26 deletions(-)
rename posthog/tasks/{detect_alerts_anomalies.py => check_alerts.py} (71%)
rename posthog/tasks/test/{test_detect_alerts_anomalies.py => test_check_alerts.py} (98%)
diff --git a/posthog/tasks/__init__.py b/posthog/tasks/__init__.py
index bd79aedec977e..8e3e665eb7785 100644
--- a/posthog/tasks/__init__.py
+++ b/posthog/tasks/__init__.py
@@ -6,7 +6,6 @@
check_clickhouse_schema_drift,
demo_create_data,
demo_reset_master_team,
- detect_alerts_anomalies,
email,
exporter,
hog_functions,
@@ -26,7 +25,6 @@
"check_clickhouse_schema_drift",
"demo_create_data",
"demo_reset_master_team",
- "detect_alerts_anomalies",
"email",
"exporter",
"hog_functions",
diff --git a/posthog/tasks/detect_alerts_anomalies.py b/posthog/tasks/check_alerts.py
similarity index 71%
rename from posthog/tasks/detect_alerts_anomalies.py
rename to posthog/tasks/check_alerts.py
index 3351c6a172c25..44a811a55f1f5 100644
--- a/posthog/tasks/detect_alerts_anomalies.py
+++ b/posthog/tasks/check_alerts.py
@@ -1,5 +1,6 @@
import structlog
from celery import shared_task
+from celery.canvas import group, chain
from django.utils import timezone
from posthog.api.services.query import ExecutionMode
@@ -15,20 +16,41 @@
def check_all_alerts() -> None:
- alerts = Alert.objects.all().only("id")
- for alert in alerts:
- logger.info("scheduling alert", alert_id=alert.id)
- _check_alert_task.delay(alert.id)
+ alert_ids = list(Alert.objects.all().values_list("id", flat=True))
+
+ group_count = 10
+ chunk_size = 10
+
+ alert_id_groups = [alert_ids[i : i + group_count] for i in range(0, len(alert_ids), group_count)]
+ task_groups = group(
+ chain(
+ *(
+ check_alert_task.chunks(
+ [(alert_id,) for alert_id in g],
+ chunk_size,
+ )
+ for g in alert_id_groups
+ )
+ )
+ )
+
+ task_groups.apply_async()
@shared_task(ignore_result=True)
-def _check_alert_task(id: int) -> None:
- _check_alert(id)
+def check_all_alerts_task() -> None:
+ check_all_alerts()
-def _check_alert(id: int) -> None:
- alert = Alert.objects.get(pk=id)
+@shared_task(ignore_result=True)
+def check_alert_task(alert_id: int) -> None:
+ check_alert(alert_id)
+
+
+def check_alert(alert_id: int) -> None:
+ alert = Alert.objects.get(pk=alert_id)
insight = alert.insight
+
with conversion_to_query_based(insight):
calculation_result = calculate_for_query_based_insight(
insight,
@@ -37,7 +59,7 @@ def _check_alert(id: int) -> None:
)
if not calculation_result.result:
- raise RuntimeError(f"no results for alert {alert.id}")
+ raise RuntimeError(f"No results for alert {alert.id}")
anomaly_condition = AnomalyCondition.model_validate(alert.anomaly_condition)
thresholds = anomaly_condition.absoluteThreshold
@@ -56,16 +78,16 @@ def _check_alert(id: int) -> None:
]
if not anomalies_descriptions:
- logger.info("no anomalies", alert_id=alert.id)
+ logger.info("No threshold met", alert_id=alert.id)
return
- _send_notifications(alert, anomalies_descriptions)
+ send_notifications(alert, anomalies_descriptions)
# TODO: make it a task
-def _send_notifications(alert: Alert, anomalies_descriptions: list[str]) -> None:
+def send_notifications(alert: Alert, anomalies_descriptions: list[str]) -> None:
subject = f"PostHog alert {alert.name} has anomalies"
- campaign_key = f"alert-anomaly-notification-{alert.id}-{timezone.now().timestamp()}"
+ campaign_key = f"alert-anomaly-notification-{alert.id}-{timezone.now().isoformat()}"
insight_url = f"/project/{alert.team.pk}/insights/{alert.insight.short_id}"
alert_url = f"{insight_url}/alerts/{alert.id}"
message = EmailMessage(
diff --git a/posthog/tasks/scheduled.py b/posthog/tasks/scheduled.py
index cdeb5718ad33d..9a1f4bd7bba06 100644
--- a/posthog/tasks/scheduled.py
+++ b/posthog/tasks/scheduled.py
@@ -7,6 +7,7 @@
from django.conf import settings
from posthog.celery import app
+from posthog.tasks.check_alerts import check_all_alerts_task
from posthog.tasks.tasks import (
calculate_cohort,
calculate_decide_usage,
@@ -25,7 +26,6 @@
clickhouse_row_count,
clickhouse_send_license_usage,
delete_expired_exported_assets,
- detect_alerts_anomalies,
ee_persist_finished_recordings,
find_flags_with_enriched_analytics,
graphile_worker_queue_size,
@@ -254,7 +254,7 @@ def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None:
sender.add_periodic_task(
crontab(hour="*", minute="20"),
- detect_alerts_anomalies.s(),
+ check_all_alerts_task.s(),
name="detect alerts' anomalies and notify about them",
)
diff --git a/posthog/tasks/tasks.py b/posthog/tasks/tasks.py
index 455ed086e3822..6a592febe4dea 100644
--- a/posthog/tasks/tasks.py
+++ b/posthog/tasks/tasks.py
@@ -782,13 +782,6 @@ def update_survey_iteration() -> None:
update_survey_iteration()
-@shared_task(ignore_result=True)
-def detect_alerts_anomalies() -> None:
- from posthog.tasks.detect_alerts_anomalies import check_all_alerts
-
- check_all_alerts()
-
-
def recompute_materialized_columns_enabled() -> bool:
from posthog.models.instance_setting import get_instance_setting
diff --git a/posthog/tasks/test/test_detect_alerts_anomalies.py b/posthog/tasks/test/test_check_alerts.py
similarity index 98%
rename from posthog/tasks/test/test_detect_alerts_anomalies.py
rename to posthog/tasks/test/test_check_alerts.py
index 47881d551d191..b47b1c8ba94c5 100644
--- a/posthog/tasks/test/test_detect_alerts_anomalies.py
+++ b/posthog/tasks/test/test_check_alerts.py
@@ -5,15 +5,15 @@
from freezegun import freeze_time
from posthog.models.instance_setting import set_instance_setting
+from posthog.tasks.check_alerts import check_all_alerts
from posthog.test.base import APIBaseTest, _create_event, flush_persons_and_events, ClickhouseDestroyTablesMixin
from posthog.api.test.dashboards import DashboardAPI
from posthog.schema import ChartDisplayType, EventsNode, TrendsQuery, TrendsFilter
from posthog.tasks.test.utils_email_tests import mock_email_messages
-from posthog.tasks.detect_alerts_anomalies import check_all_alerts
@freeze_time("2024-06-02T08:55:00.000Z")
-@patch("posthog.tasks.detect_alerts_anomalies.EmailMessage")
+@patch("posthog.tasks.check_alerts.EmailMessage")
class TestDetectAlertsAnomaliesTasks(APIBaseTest, ClickhouseDestroyTablesMixin):
def setUp(self) -> None:
super().setUp()
From 712d780b4f5d45eb9202e3cabbee34305eaf57c7 Mon Sep 17 00:00:00 2001
From: Julian Bez
Date: Fri, 5 Jul 2024 15:22:45 +0200
Subject: [PATCH 12/20] Fix scheduled task setup
Somehow it didn't work for me
---
posthog/tasks/scheduled.py | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/posthog/tasks/scheduled.py b/posthog/tasks/scheduled.py
index 9a1f4bd7bba06..1a2bbbce86857 100644
--- a/posthog/tasks/scheduled.py
+++ b/posthog/tasks/scheduled.py
@@ -93,6 +93,8 @@ def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None:
add_periodic_task_with_expiry(sender, 20, start_poll_query_performance.s(), "20 sec query performance heartbeat")
+ add_periodic_task_with_expiry(sender, 60 * 60, check_all_alerts_task.s(), "check all alerts")
+
# Update events table partitions twice a week
sender.add_periodic_task(
crontab(day_of_week="mon,fri", hour="0", minute="0"),
@@ -252,12 +254,6 @@ def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None:
name="update survey iteration based on date",
)
- sender.add_periodic_task(
- crontab(hour="*", minute="20"),
- check_all_alerts_task.s(),
- name="detect alerts' anomalies and notify about them",
- )
-
if settings.EE_AVAILABLE:
# every interval seconds, we calculate N replay embeddings
# the goal is to process _enough_ every 24 hours that
From f179e37196fc9ef47f902914e63a51480b8d3b3b Mon Sep 17 00:00:00 2001
From: Julian Bez
Date: Fri, 5 Jul 2024 15:28:38 +0200
Subject: [PATCH 13/20] Refactor more
---
posthog/tasks/alerts/__init__.py | 0
.../{check_alerts.py => alerts/checks.py} | 20 +++++++++----------
posthog/tasks/alerts/test/__init__.py | 0
.../test/test_checks.py} | 4 ++--
posthog/tasks/scheduled.py | 2 +-
5 files changed, 13 insertions(+), 13 deletions(-)
create mode 100644 posthog/tasks/alerts/__init__.py
rename posthog/tasks/{check_alerts.py => alerts/checks.py} (100%)
create mode 100644 posthog/tasks/alerts/test/__init__.py
rename posthog/tasks/{test/test_check_alerts.py => alerts/test/test_checks.py} (98%)
diff --git a/posthog/tasks/alerts/__init__.py b/posthog/tasks/alerts/__init__.py
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/posthog/tasks/check_alerts.py b/posthog/tasks/alerts/checks.py
similarity index 100%
rename from posthog/tasks/check_alerts.py
rename to posthog/tasks/alerts/checks.py
index 44a811a55f1f5..181c3d85c0ac6 100644
--- a/posthog/tasks/check_alerts.py
+++ b/posthog/tasks/alerts/checks.py
@@ -37,16 +37,6 @@ def check_all_alerts() -> None:
task_groups.apply_async()
-@shared_task(ignore_result=True)
-def check_all_alerts_task() -> None:
- check_all_alerts()
-
-
-@shared_task(ignore_result=True)
-def check_alert_task(alert_id: int) -> None:
- check_alert(alert_id)
-
-
def check_alert(alert_id: int) -> None:
alert = Alert.objects.get(pk=alert_id)
insight = alert.insight
@@ -84,6 +74,16 @@ def check_alert(alert_id: int) -> None:
send_notifications(alert, anomalies_descriptions)
+@shared_task(ignore_result=True)
+def check_all_alerts_task() -> None:
+ check_all_alerts()
+
+
+@shared_task(ignore_result=True)
+def check_alert_task(alert_id: int) -> None:
+ check_alert(alert_id)
+
+
# TODO: make it a task
def send_notifications(alert: Alert, anomalies_descriptions: list[str]) -> None:
subject = f"PostHog alert {alert.name} has anomalies"
diff --git a/posthog/tasks/alerts/test/__init__.py b/posthog/tasks/alerts/test/__init__.py
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/posthog/tasks/test/test_check_alerts.py b/posthog/tasks/alerts/test/test_checks.py
similarity index 98%
rename from posthog/tasks/test/test_check_alerts.py
rename to posthog/tasks/alerts/test/test_checks.py
index b47b1c8ba94c5..972d422683603 100644
--- a/posthog/tasks/test/test_check_alerts.py
+++ b/posthog/tasks/alerts/test/test_checks.py
@@ -5,7 +5,7 @@
from freezegun import freeze_time
from posthog.models.instance_setting import set_instance_setting
-from posthog.tasks.check_alerts import check_all_alerts
+from posthog.tasks.alerts.checks import check_all_alerts
from posthog.test.base import APIBaseTest, _create_event, flush_persons_and_events, ClickhouseDestroyTablesMixin
from posthog.api.test.dashboards import DashboardAPI
from posthog.schema import ChartDisplayType, EventsNode, TrendsQuery, TrendsFilter
@@ -13,7 +13,7 @@
@freeze_time("2024-06-02T08:55:00.000Z")
-@patch("posthog.tasks.check_alerts.EmailMessage")
+@patch("posthog.tasks.alerts.checks.EmailMessage")
class TestDetectAlertsAnomaliesTasks(APIBaseTest, ClickhouseDestroyTablesMixin):
def setUp(self) -> None:
super().setUp()
diff --git a/posthog/tasks/scheduled.py b/posthog/tasks/scheduled.py
index 1a2bbbce86857..52056349e36aa 100644
--- a/posthog/tasks/scheduled.py
+++ b/posthog/tasks/scheduled.py
@@ -7,7 +7,7 @@
from django.conf import settings
from posthog.celery import app
-from posthog.tasks.check_alerts import check_all_alerts_task
+from posthog.tasks.alerts.checks import check_all_alerts_task
from posthog.tasks.tasks import (
calculate_cohort,
calculate_decide_usage,
From 5cf4e4629fdeb0873604b270fb574771ae1a8ccd Mon Sep 17 00:00:00 2001
From: Julian Bez
Date: Tue, 9 Jul 2024 17:29:56 +0200
Subject: [PATCH 14/20] Fix group setup
---
posthog/tasks/alerts/checks.py | 11 ++---------
1 file changed, 2 insertions(+), 9 deletions(-)
diff --git a/posthog/tasks/alerts/checks.py b/posthog/tasks/alerts/checks.py
index 181c3d85c0ac6..e1d2b0e8cf7d7 100644
--- a/posthog/tasks/alerts/checks.py
+++ b/posthog/tasks/alerts/checks.py
@@ -23,15 +23,8 @@ def check_all_alerts() -> None:
alert_id_groups = [alert_ids[i : i + group_count] for i in range(0, len(alert_ids), group_count)]
task_groups = group(
- chain(
- *(
- check_alert_task.chunks(
- [(alert_id,) for alert_id in g],
- chunk_size,
- )
- for g in alert_id_groups
- )
- )
+ chain(check_alert_task.chunks([(alert_id,) for alert_id in alert_id_group], chunk_size))
+ for alert_id_group in alert_id_groups
)
task_groups.apply_async()
From d9404415bac1805e35eefea1417daafb9ee1bfef Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Fri, 12 Jul 2024 09:30:54 +0100
Subject: [PATCH 15/20] address comments
---
frontend/src/queries/schema.json | 1 -
frontend/src/queries/schema.ts | 4 +-
posthog/tasks/alerts/checks.py | 23 +++--
posthog/tasks/alerts/test/test_checks.py | 108 +++++++++++------------
4 files changed, 66 insertions(+), 70 deletions(-)
diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json
index 05f3a79f84963..c34c47b097433 100644
--- a/frontend/src/queries/schema.json
+++ b/frontend/src/queries/schema.json
@@ -11,7 +11,6 @@
"type": ["number", "null"]
}
},
- "required": ["lower", "upper"],
"type": "object"
},
"ActionsNode": {
diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts
index 66027e597cd27..a59da9a330f7b 100644
--- a/frontend/src/queries/schema.ts
+++ b/frontend/src/queries/schema.ts
@@ -1527,8 +1527,8 @@ export interface DashboardFilter {
}
export interface AbsoluteThreshold {
- lower: number | null
- upper: number | null
+ lower?: number | null
+ upper?: number | null
}
export interface AnomalyCondition {
diff --git a/posthog/tasks/alerts/checks.py b/posthog/tasks/alerts/checks.py
index e1d2b0e8cf7d7..d6f811352e60c 100644
--- a/posthog/tasks/alerts/checks.py
+++ b/posthog/tasks/alerts/checks.py
@@ -2,6 +2,7 @@
from celery import shared_task
from celery.canvas import group, chain
from django.utils import timezone
+import math
from posthog.api.services.query import ExecutionMode
from posthog.caching.calculate_results import calculate_for_query_based_insight
@@ -19,15 +20,17 @@ def check_all_alerts() -> None:
alert_ids = list(Alert.objects.all().values_list("id", flat=True))
group_count = 10
- chunk_size = 10
+ # All groups but the last one will have a group_size size.
+ # The last group will have at most group_size size.
+ group_size = int(math.ceil(len(alert_ids) / group_count))
- alert_id_groups = [alert_ids[i : i + group_count] for i in range(0, len(alert_ids), group_count)]
- task_groups = group(
- chain(check_alert_task.chunks([(alert_id,) for alert_id in alert_id_group], chunk_size))
- for alert_id_group in alert_id_groups
- )
+ groups = []
+ for i in range(0, len(alert_ids), group_size):
+ alert_id_group = alert_ids[i : i + group_size]
+ chained_calls = chain([check_alert_task.s(alert_id=alert_id) for alert_id in alert_id_group])
+ groups.append(chained_calls)
- task_groups.apply_async()
+ group(groups).apply_async()
def check_alert(alert_id: int) -> None:
@@ -72,9 +75,11 @@ def check_all_alerts_task() -> None:
check_all_alerts()
+# Note, check_alert_task is used in Celery chains. Celery chains pass the previous
+# function call result to the next function as an argument, hence args and kwargs.
@shared_task(ignore_result=True)
-def check_alert_task(alert_id: int) -> None:
- check_alert(alert_id)
+def check_alert_task(*args, **kwargs) -> None:
+ check_alert(**kwargs)
# TODO: make it a task
diff --git a/posthog/tasks/alerts/test/test_checks.py b/posthog/tasks/alerts/test/test_checks.py
index 972d422683603..fb5f93b3cb166 100644
--- a/posthog/tasks/alerts/test/test_checks.py
+++ b/posthog/tasks/alerts/test/test_checks.py
@@ -1,25 +1,25 @@
import pytest
-from typing import Any, Optional
+from typing import Optional
from unittest.mock import MagicMock, patch
from freezegun import freeze_time
from posthog.models.instance_setting import set_instance_setting
-from posthog.tasks.alerts.checks import check_all_alerts
+from posthog.tasks.alerts.checks import send_notifications, check_alert
from posthog.test.base import APIBaseTest, _create_event, flush_persons_and_events, ClickhouseDestroyTablesMixin
from posthog.api.test.dashboards import DashboardAPI
from posthog.schema import ChartDisplayType, EventsNode, TrendsQuery, TrendsFilter
from posthog.tasks.test.utils_email_tests import mock_email_messages
+from posthog.models import Alert
@freeze_time("2024-06-02T08:55:00.000Z")
-@patch("posthog.tasks.alerts.checks.EmailMessage")
-class TestDetectAlertsAnomaliesTasks(APIBaseTest, ClickhouseDestroyTablesMixin):
+@patch("posthog.tasks.alerts.checks.send_notifications")
+class TestCheckAlertsTasks(APIBaseTest, ClickhouseDestroyTablesMixin):
def setUp(self) -> None:
super().setUp()
set_instance_setting("EMAIL_HOST", "fake_host")
set_instance_setting("EMAIL_ENABLED", True)
- self.settings(CELERY_TASK_ALWAYS_EAGER=True)
self.dashboard_api = DashboardAPI(self.client, self.team, self.assertEqual)
query_dict = TrendsQuery(
series=[
@@ -52,12 +52,10 @@ def set_thresholds(self, lower: Optional[int] = None, upper: Optional[int] = Non
data={"anomaly_condition": {"absoluteThreshold": {"lower": lower, "upper": upper}}},
)
- def get_recepients(self, mocked_email_messages: list[Any]) -> list[list[str]]:
- recipients = [sorted([to["recipient"] for to in message.to]) for message in mocked_email_messages]
- return sorted(recipients)
+ def get_anomalies_descriptions(self, mock_send_notifications: MagicMock, call_index: int) -> list[str]:
+ return mock_send_notifications.call_args_list[call_index].args[1]
- def test_alert_is_triggered_for_values_above_higher_threshold(self, MockEmailMessage: MagicMock) -> None:
- mocked_email_messages = mock_email_messages(MockEmailMessage)
+ def test_alert_is_triggered_for_values_above_higher_threshold(self, mock_send_notifications: MagicMock) -> None:
self.set_thresholds(upper=0)
with freeze_time("2024-06-02T07:55:00.000Z"):
@@ -68,14 +66,17 @@ def test_alert_is_triggered_for_values_above_higher_threshold(self, MockEmailMes
)
flush_persons_and_events()
- check_all_alerts()
+ check_alert(self.alert["id"])
- assert len(mocked_email_messages) == 1
- assert self.get_recepients(mocked_email_messages) == [["a@b.c", "d@e.f"]]
- assert "The trend value (1) is above the upper threshold (0.0)" in mocked_email_messages[0].html_body
+ assert mock_send_notifications.call_count == 1
+ alert = mock_send_notifications.call_args_list[0].args[0]
+ assert alert.id == self.alert["id"]
- def test_alert_is_not_triggered_for_events_beyond_interval(self, MockEmailMessage: MagicMock) -> None:
- mocked_email_messages = mock_email_messages(MockEmailMessage)
+ anomalies_descriptions = self.get_anomalies_descriptions(mock_send_notifications, call_index=0)
+ assert len(anomalies_descriptions) == 1
+ assert "The trend value (1) is above the upper threshold (0.0)" in anomalies_descriptions[0]
+
+ def test_alert_is_not_triggered_for_events_beyond_interval(self, mock_send_notifications: MagicMock) -> None:
self.set_thresholds(upper=0)
with freeze_time("2024-05-02T07:55:00.000Z"):
@@ -86,35 +87,35 @@ def test_alert_is_not_triggered_for_events_beyond_interval(self, MockEmailMessag
)
flush_persons_and_events()
- check_all_alerts()
+ check_alert(self.alert["id"])
- assert len(mocked_email_messages) == 0
+ assert mock_send_notifications.call_count == 0
- def test_alert_is_triggered_for_value_below_lower_threshold(self, MockEmailMessage: MagicMock) -> None:
- mocked_email_messages = mock_email_messages(MockEmailMessage)
+ def test_alert_is_triggered_for_value_below_lower_threshold(self, mock_send_notifications: MagicMock) -> None:
self.set_thresholds(lower=1)
- check_all_alerts()
+ check_alert(self.alert["id"])
- assert len(mocked_email_messages) == 1
- assert "The trend value (0) is below the lower threshold (1.0)" in mocked_email_messages[0].html_body
+ assert mock_send_notifications.call_count == 1
+ anomalies = self.get_anomalies_descriptions(mock_send_notifications, call_index=0)
+ assert "The trend value (0) is below the lower threshold (1.0)" in anomalies
- def test_alert_is_not_triggered_for_normal_values(self, MockEmailMessage: MagicMock) -> None:
- mocked_email_messages = mock_email_messages(MockEmailMessage)
+ def test_alert_is_not_triggered_for_normal_values(self, mock_send_notifications: MagicMock) -> None:
self.set_thresholds(lower=0, upper=1)
- check_all_alerts()
+ check_alert(self.alert["id"])
- assert len(mocked_email_messages) == 0
+ assert mock_send_notifications.call_count == 0
- def test_error_while_calculating_no_alert(self, MockEmailMessage: MagicMock) -> None:
- mocked_email_messages = mock_email_messages(MockEmailMessage)
+ def test_error_while_calculating_no_alert(self, mock_send_notifications: MagicMock) -> None:
query_dict = TrendsQuery(
series=[
EventsNode(
event="$pageview",
),
],
+ # This query is not represented as a bold number, so calculating the insight aggregated value
+ # causes an error
).model_dump()
insight = self.dashboard_api.create_insight(
data={
@@ -125,35 +126,11 @@ def test_error_while_calculating_no_alert(self, MockEmailMessage: MagicMock) ->
self.client.patch(f"/api/projects/{self.team.id}/alerts/{self.alert['id']}", data={"insight": insight["id"]})
- # in production failure of a single alert won't cause an exception in check_all_alerts
- # because the execution won't be eager (see CELERY_TASK_ALWAYS_EAGER in the set up)
with pytest.raises(KeyError):
- check_all_alerts()
-
- assert len(mocked_email_messages) == 0
+ check_alert(self.alert["id"])
+ assert mock_send_notifications.call_count == 0
- def test_two_alerts_are_triggered(self, MockEmailMessage: MagicMock) -> None:
- mocked_email_messages = mock_email_messages(MockEmailMessage)
- self.set_thresholds(lower=1)
- self.client.post(
- f"/api/projects/{self.team.id}/alerts",
- data={
- "name": "another alert name",
- "insight": self.insight["id"],
- "target_value": "email@address.com",
- "anomaly_condition": {"absoluteThreshold": {"lower": 1}},
- },
- ).json()
-
- check_all_alerts()
-
- assert len(mocked_email_messages) == 2
- assert "The trend value (0) is below the lower threshold (1.0)" in mocked_email_messages[0].html_body
- assert "The trend value (0) is below the lower threshold (1.0)" in mocked_email_messages[1].html_body
- assert self.get_recepients(mocked_email_messages) == [["a@b.c", "d@e.f"], ["email@address.com"]]
-
- def test_alert_with_insight_with_filter(self, MockEmailMessage: MagicMock) -> None:
- mocked_email_messages = mock_email_messages(MockEmailMessage)
+ def test_alert_with_insight_with_filter(self, mock_send_notifications: MagicMock) -> None:
insight = self.dashboard_api.create_insight(
data={"name": "insight", "filters": {"events": [{"id": "$pageview"}], "display": "BoldNumber"}}
)[1]
@@ -161,7 +138,22 @@ def test_alert_with_insight_with_filter(self, MockEmailMessage: MagicMock) -> No
self.client.patch(f"/api/projects/{self.team.id}/alerts/{self.alert['id']}", data={"insight": insight["id"]})
self.set_thresholds(lower=1)
- check_all_alerts()
+ check_alert(self.alert["id"])
+
+ assert mock_send_notifications.call_count == 1
+ anomalies = self.get_anomalies_descriptions(mock_send_notifications, call_index=0)
+ assert "The trend value (0) is below the lower threshold (1.0)" in anomalies
+
+ @patch("posthog.tasks.alerts.checks.EmailMessage")
+ def test_send_emails(self, MockEmailMessage: MagicMock, mock_send_notifications: MagicMock) -> None:
+ mocked_email_messages = mock_email_messages(MockEmailMessage)
+ alert = Alert.objects.get(pk=self.alert["id"])
+ send_notifications(alert, ["first anomaly description", "second anomaly description"])
assert len(mocked_email_messages) == 1
- assert "The trend value (0) is below the lower threshold (1.0)" in mocked_email_messages[0].html_body
+ email = mocked_email_messages[0]
+ assert len(email.to) == 2
+ assert email.to[0]["recipient"] == "a@b.c"
+ assert email.to[1]["recipient"] == "d@e.f"
+ assert "first anomaly description" in email.html_body
+ assert "second anomaly description" in email.html_body
From ed27736f8074e371aa11304c47c0120c09f777bc Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Sat, 13 Jul 2024 11:23:25 +0100
Subject: [PATCH 16/20] fix typing
---
posthog/tasks/alerts/checks.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/posthog/tasks/alerts/checks.py b/posthog/tasks/alerts/checks.py
index d6f811352e60c..c1dc1fbaa92fa 100644
--- a/posthog/tasks/alerts/checks.py
+++ b/posthog/tasks/alerts/checks.py
@@ -1,8 +1,9 @@
-import structlog
from celery import shared_task
from celery.canvas import group, chain
from django.utils import timezone
import math
+import structlog
+from typing import Any
from posthog.api.services.query import ExecutionMode
from posthog.caching.calculate_results import calculate_for_query_based_insight
@@ -78,7 +79,7 @@ def check_all_alerts_task() -> None:
# Note, check_alert_task is used in Celery chains. Celery chains pass the previous
# function call result to the next function as an argument, hence args and kwargs.
@shared_task(ignore_result=True)
-def check_alert_task(*args, **kwargs) -> None:
+def check_alert_task(*args: Any, **kwargs: Any) -> None:
check_alert(**kwargs)
From 4e9716058c5c9c0626827cd96837f93f0ecc6891 Mon Sep 17 00:00:00 2001
From: Julian Bez
Date: Wed, 17 Jul 2024 11:46:55 +0200
Subject: [PATCH 17/20] Revert "Fix scheduled task setup"
This reverts commit 712d780b4f5d45eb9202e3cabbee34305eaf57c7.
---
posthog/tasks/scheduled.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/posthog/tasks/scheduled.py b/posthog/tasks/scheduled.py
index 52056349e36aa..1fd85e599f6d8 100644
--- a/posthog/tasks/scheduled.py
+++ b/posthog/tasks/scheduled.py
@@ -93,8 +93,6 @@ def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None:
add_periodic_task_with_expiry(sender, 20, start_poll_query_performance.s(), "20 sec query performance heartbeat")
- add_periodic_task_with_expiry(sender, 60 * 60, check_all_alerts_task.s(), "check all alerts")
-
# Update events table partitions twice a week
sender.add_periodic_task(
crontab(day_of_week="mon,fri", hour="0", minute="0"),
@@ -254,6 +252,12 @@ def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None:
name="update survey iteration based on date",
)
+ sender.add_periodic_task(
+ crontab(hour="*", minute="20"),
+ check_all_alerts_task.s(),
+ name="detect alerts' anomalies and notify about them",
+ )
+
if settings.EE_AVAILABLE:
# every interval seconds, we calculate N replay embeddings
# the goal is to process _enough_ every 24 hours that
From 028d155059def4cf31a5b78e949eed3ba25fe323 Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Thu, 18 Jul 2024 19:24:05 +0100
Subject: [PATCH 18/20] use si for chains
---
posthog/tasks/alerts/checks.py | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/posthog/tasks/alerts/checks.py b/posthog/tasks/alerts/checks.py
index c1dc1fbaa92fa..4ed6d29114401 100644
--- a/posthog/tasks/alerts/checks.py
+++ b/posthog/tasks/alerts/checks.py
@@ -3,7 +3,6 @@
from django.utils import timezone
import math
import structlog
-from typing import Any
from posthog.api.services.query import ExecutionMode
from posthog.caching.calculate_results import calculate_for_query_based_insight
@@ -28,7 +27,7 @@ def check_all_alerts() -> None:
groups = []
for i in range(0, len(alert_ids), group_size):
alert_id_group = alert_ids[i : i + group_size]
- chained_calls = chain([check_alert_task.s(alert_id=alert_id) for alert_id in alert_id_group])
+ chained_calls = chain([check_alert_task.si(alert_id) for alert_id in alert_id_group])
groups.append(chained_calls)
group(groups).apply_async()
@@ -76,11 +75,9 @@ def check_all_alerts_task() -> None:
check_all_alerts()
-# Note, check_alert_task is used in Celery chains. Celery chains pass the previous
-# function call result to the next function as an argument, hence args and kwargs.
@shared_task(ignore_result=True)
-def check_alert_task(*args: Any, **kwargs: Any) -> None:
- check_alert(**kwargs)
+def check_alert_task(alert_id: int) -> None:
+ check_alert(alert_id)
# TODO: make it a task
From fa7dc4cfdfc62abe72f8f19a51fb03df5fcbead0 Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Thu, 18 Jul 2024 22:10:43 +0100
Subject: [PATCH 19/20] use timestamp for the campaign key
---
posthog/tasks/alerts/checks.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/posthog/tasks/alerts/checks.py b/posthog/tasks/alerts/checks.py
index 4ed6d29114401..28b964b338af8 100644
--- a/posthog/tasks/alerts/checks.py
+++ b/posthog/tasks/alerts/checks.py
@@ -83,7 +83,7 @@ def check_alert_task(alert_id: int) -> None:
# TODO: make it a task
def send_notifications(alert: Alert, anomalies_descriptions: list[str]) -> None:
subject = f"PostHog alert {alert.name} has anomalies"
- campaign_key = f"alert-anomaly-notification-{alert.id}-{timezone.now().isoformat()}"
+ campaign_key = f"alert-anomaly-notification-{alert.id}-{timezone.now().timestamp()}"
insight_url = f"/project/{alert.team.pk}/insights/{alert.insight.short_id}"
alert_url = f"{insight_url}/alerts/{alert.id}"
message = EmailMessage(
From 66f0c4ac265d20eef0ae4ee0db4e9f9e99123880 Mon Sep 17 00:00:00 2001
From: Nikita Vorobev
Date: Tue, 6 Aug 2024 18:21:59 +0100
Subject: [PATCH 20/20] brush up the PR
---
frontend/src/types.ts | 9 +--------
posthog/tasks/alerts/checks.py | 1 -
posthog/templates/email/alert_anomaly.html | 2 +-
3 files changed, 2 insertions(+), 10 deletions(-)
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index a81e03640e793..495e8b7daab90 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -33,6 +33,7 @@ import { Scene } from 'scenes/sceneTypes'
import { QueryContext } from '~/queries/types'
import type {
+ AnomalyCondition,
DashboardFilter,
DatabaseSchemaField,
HogQLQuery,
@@ -4370,14 +4371,6 @@ export type HogFunctionInvocationGlobals = {
>
}
-// TODO: move to schema.ts
-export interface AnomalyCondition {
- absoluteThreshold: {
- lower?: number
- upper?: number
- }
-}
-
export interface AlertType {
id: number
name: string
diff --git a/posthog/tasks/alerts/checks.py b/posthog/tasks/alerts/checks.py
index 28b964b338af8..e5c34f578a048 100644
--- a/posthog/tasks/alerts/checks.py
+++ b/posthog/tasks/alerts/checks.py
@@ -80,7 +80,6 @@ def check_alert_task(alert_id: int) -> None:
check_alert(alert_id)
-# TODO: make it a task
def send_notifications(alert: Alert, anomalies_descriptions: list[str]) -> None:
subject = f"PostHog alert {alert.name} has anomalies"
campaign_key = f"alert-anomaly-notification-{alert.id}-{timezone.now().timestamp()}"
diff --git a/posthog/templates/email/alert_anomaly.html b/posthog/templates/email/alert_anomaly.html
index c50dafd157cc3..49636488288dc 100644
--- a/posthog/templates/email/alert_anomaly.html
+++ b/posthog/templates/email/alert_anomaly.html
@@ -1,6 +1,6 @@
{% extends "email/base.html" %} {% load posthog_assets %} {% block section %}
- Uh-oh, the {{ alert_name }} alert detected following anomalies for {{ insight_name }}:
+ The {{ alert_name }} alert detected following anomalies for {{ insight_name }}:
{% for anomaly_description in anomalies_descriptions %}
- {{ anomaly_description }}