From 21544a9aa3dfd70840b8323f77e2306b22e0e334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Tue, 23 Jun 2026 15:52:54 +0200 Subject: [PATCH 01/13] Add function to compose message for new alerts --- .../src/oonipipeline/analysis/detector.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 05c885ab..5287338a 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -3,6 +3,7 @@ from enum import Enum from itertools import groupby as itertools_groupby from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple +import requests from clickhouse_driver import Client as ClickhouseClient @@ -649,3 +650,33 @@ def make_label(df, field, label, color): .interactive() ) chart.show() + +def notify_slack(db : ClickhouseClient, changepoints: list[Changepoint], slack_webhook : str): + """ + Notifies that there was a change in blocking state of a relevant target + """ + + if len(changepoints) == 0: + return + + message = """ + **NEW EVENTS DETECTED** + + We just detected the following blocking events: + """ + + change_dir_str = { + -1 : "is unblocked :arrow_down: :large_green_circle:", + 0 : "0? :thinking_face:", + 1 : "is blocked :arrow_up: :red_circle:" + } + + for cp in changepoints: + message = message + f""" + \t- :flag-{cp.probe_cc.lower()}:[{cp.probe_cc}/AS{cp.probe_asn}] **{cp.domain}** {change_dir_str[cp.change_dir]} - `{cp.block_type}` + """ + + # Send message to slack + requests.post(slack_webhook, json = { + "text" : message + }) From e43623f214fe46a1734fb5b5072e6324950e3761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Wed, 24 Jun 2026 15:38:29 +0200 Subject: [PATCH 02/13] improve the message format --- .../src/oonipipeline/analysis/detector.py | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 5287338a..4353a6f2 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -532,6 +532,8 @@ def run_detector( ) update_tables(db, updated_cusums, changepoints) + notify_slack(db, changepoints) + return changepoints, updated_cusums, steps @@ -660,7 +662,7 @@ def notify_slack(db : ClickhouseClient, changepoints: list[Changepoint], slack_w return message = """ - **NEW EVENTS DETECTED** + *NEW EVENTS DETECTED* We just detected the following blocking events: """ @@ -671,12 +673,40 @@ def notify_slack(db : ClickhouseClient, changepoints: list[Changepoint], slack_w 1 : "is blocked :arrow_up: :red_circle:" } + # Divide the message in several parts + for cp in changepoints: + explorer = get_explorer_url(cp) message = message + f""" - \t- :flag-{cp.probe_cc.lower()}:[{cp.probe_cc}/AS{cp.probe_asn}] **{cp.domain}** {change_dir_str[cp.change_dir]} - `{cp.block_type}` + \t- :flag-{cp.probe_cc.lower()}:[{cp.probe_cc}/AS{cp.probe_asn}] *{cp.domain}* {change_dir_str[cp.change_dir]} - `{cp.block_type}` | <{explorer}|explorer> """ # Send message to slack requests.post(slack_webhook, json = { "text" : message }) + +def send_to_slack(webhook : str, message: str): + requests.post(webhook, json = { + "text" : message + }).raise_for_status() + +def get_explorer_url(changepoint: Changepoint, base_url: str = "https://explorer.ooni.org/") -> str: + start_time = changepoint.ts - timedelta(days=5) + end_time = changepoint.ts + timedelta(days=5) + + def to_s(dt: datetime): + return datetime.strftime(dt, "%Y-%m-%d") + + from urllib.parse import urlencode + + params = { + "domain": changepoint.domain, + "probe_cc": changepoint.probe_cc, + "probe_asn": changepoint.probe_asn, + "since": to_s(start_time), + "until": to_s(end_time), + "axis_x": "measurement_start_day", + } + url = f"{base_url}/chart/mat?{urlencode(params)}" + return url From c34f610fa8389b4a4e80375cd628bcd54a029d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Wed, 24 Jun 2026 15:39:44 +0200 Subject: [PATCH 03/13] Use send_to_slack function for easier patching --- oonipipeline/src/oonipipeline/analysis/detector.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 4353a6f2..9d04861e 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -682,9 +682,7 @@ def notify_slack(db : ClickhouseClient, changepoints: list[Changepoint], slack_w """ # Send message to slack - requests.post(slack_webhook, json = { - "text" : message - }) + send_to_slack(slack_webhook, message) def send_to_slack(webhook : str, message: str): requests.post(webhook, json = { From f85e6799ede18e45efb4fb9467ea9b57fa659cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Thu, 25 Jun 2026 12:24:11 +0200 Subject: [PATCH 04/13] Split messages in 10 entries batches --- .../src/oonipipeline/analysis/detector.py | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 9d04861e..8736351c 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -508,6 +508,7 @@ def run_detector( gap_halflife: float = 48.0, warmup: bool = False, trace: bool = False, + slack_webhook: str | None = None ) -> Tuple[List[Changepoint], List[LastCusum], List[CusumStep]]: db = ClickhouseClient.from_url(clickhouse_url) domains = get_domain_list(db) @@ -532,7 +533,8 @@ def run_detector( ) update_tables(db, updated_cusums, changepoints) - notify_slack(db, changepoints) + if slack_webhook is not None: + notify_slack(db, changepoints, slack_webhook) return changepoints, updated_cusums, steps @@ -661,11 +663,7 @@ def notify_slack(db : ClickhouseClient, changepoints: list[Changepoint], slack_w if len(changepoints) == 0: return - message = """ - *NEW EVENTS DETECTED* - - We just detected the following blocking events: - """ + message = "*NEW EVENTS DETECTED*\n\nWe just detected the following blocking events:\n" change_dir_str = { -1 : "is unblocked :arrow_down: :large_green_circle:", @@ -673,16 +671,26 @@ def notify_slack(db : ClickhouseClient, changepoints: list[Changepoint], slack_w 1 : "is blocked :arrow_up: :red_circle:" } - # Divide the message in several parts - - for cp in changepoints: + messages = [] + for (i ,cp) in enumerate(changepoints): explorer = get_explorer_url(cp) - message = message + f""" - \t- :flag-{cp.probe_cc.lower()}:[{cp.probe_cc}/AS{cp.probe_asn}] *{cp.domain}* {change_dir_str[cp.change_dir]} - `{cp.block_type}` | <{explorer}|explorer> - """ + message += ( + f"• :flag-{cp.probe_cc.lower()}: [{cp.probe_cc}/AS{cp.probe_asn}] " + f"*{cp.domain}* {change_dir_str[cp.change_dir]} - `{cp.block_type}` " + f"| <{explorer}|explorer>\n" + ) + + # Send messages in 10 entries batches to avoid max message size limit + if (i+1) % 10 == 0: + messages.append(message) + message = "" + + if message != "": + messages.append(message) - # Send message to slack - send_to_slack(slack_webhook, message) + # Send messages to slack + for msg in messages: + send_to_slack(slack_webhook, msg) def send_to_slack(webhook : str, message: str): requests.post(webhook, json = { From 90dac874d99628774de3897ac97dc3d97d54e85a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Thu, 25 Jun 2026 13:18:35 +0200 Subject: [PATCH 05/13] Add slack webhook variable to dag --- dags/pipeline.py | 6 ++++-- .../src/oonipipeline/analysis/detector.py | 16 ++++++++-------- oonipipeline/src/oonipipeline/tasks/detector.py | 2 ++ oonipipeline/tests/test_e2e.py | 2 +- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/dags/pipeline.py b/dags/pipeline.py index 7fba8e07..e5a9c41b 100644 --- a/dags/pipeline.py +++ b/dags/pipeline.py @@ -63,7 +63,7 @@ def run_make_analysis( def run_make_event_detector( - clickhouse_url: str, probe_cc: List[str], timestamp: str = "", ts: str = "" + clickhouse_url: str, probe_cc: List[str], timestamp: str = "", ts: str = "", slack_webhook : str | None = None ): from oonipipeline.tasks.detector import MakeDetectorParams, make_detector @@ -71,7 +71,8 @@ def run_make_event_detector( timestamp = ts[:13] params = MakeDetectorParams( - clickhouse_url=clickhouse_url, probe_cc=probe_cc, timestamp=timestamp + clickhouse_url=clickhouse_url, probe_cc=probe_cc, timestamp=timestamp, + slack_webhook=slack_webhook ) make_detector(params) @@ -229,6 +230,7 @@ def run_make_time_inconsistencies_analysis( op_kwargs={ "probe_cc": dag_full.params["probe_cc"], "clickhouse_url": Variable.get("clickhouse_url", default_var=""), + "slack_webhook": Variable.get("slack_webhook", default_var=None), "ts": "{{ts}}", }, requirements=REQUIREMENTS, diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 8736351c..bf72858d 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -672,11 +672,11 @@ def notify_slack(db : ClickhouseClient, changepoints: list[Changepoint], slack_w } messages = [] - for (i ,cp) in enumerate(changepoints): + for (i,cp) in enumerate(changepoints): explorer = get_explorer_url(cp) message += ( - f"• :flag-{cp.probe_cc.lower()}: [{cp.probe_cc}/AS{cp.probe_asn}] " - f"*{cp.domain}* {change_dir_str[cp.change_dir]} - `{cp.block_type}` " + f"• :flag-{cp['probe_cc'].lower()}: [{cp['probe_cc']}/AS{cp['probe_asn']}] " + f"*{cp['domain']}* {change_dir_str[cp['change_dir']]} - `{cp['block_type']}` " f"| <{explorer}|explorer>\n" ) @@ -698,8 +698,8 @@ def send_to_slack(webhook : str, message: str): }).raise_for_status() def get_explorer_url(changepoint: Changepoint, base_url: str = "https://explorer.ooni.org/") -> str: - start_time = changepoint.ts - timedelta(days=5) - end_time = changepoint.ts + timedelta(days=5) + start_time = changepoint['ts'] - timedelta(days=5) + end_time = changepoint['ts'] + timedelta(days=5) def to_s(dt: datetime): return datetime.strftime(dt, "%Y-%m-%d") @@ -707,9 +707,9 @@ def to_s(dt: datetime): from urllib.parse import urlencode params = { - "domain": changepoint.domain, - "probe_cc": changepoint.probe_cc, - "probe_asn": changepoint.probe_asn, + "domain": changepoint['domain'], + "probe_cc": changepoint['probe_cc'], + "probe_asn": changepoint['probe_asn'], "since": to_s(start_time), "until": to_s(end_time), "axis_x": "measurement_start_day", diff --git a/oonipipeline/src/oonipipeline/tasks/detector.py b/oonipipeline/src/oonipipeline/tasks/detector.py index 1e0a0e90..86aa7c70 100644 --- a/oonipipeline/src/oonipipeline/tasks/detector.py +++ b/oonipipeline/src/oonipipeline/tasks/detector.py @@ -10,6 +10,7 @@ class MakeDetectorParams: clickhouse_url: str probe_cc: List[str] timestamp: str + slack_webhook: str | None def make_detector(params: MakeDetectorParams): @@ -29,4 +30,5 @@ def make_detector(params: MakeDetectorParams): start_time=start_hour, end_time=end_hour, probe_cc=params.probe_cc, + slack_webhook=params.slack_webhook ) diff --git a/oonipipeline/tests/test_e2e.py b/oonipipeline/tests/test_e2e.py index 6f26780b..8881b0d3 100644 --- a/oonipipeline/tests/test_e2e.py +++ b/oonipipeline/tests/test_e2e.py @@ -123,7 +123,7 @@ def test_event_detector(datadir, db): ) make_detector( MakeDetectorParams( - clickhouse_url=db.clickhouse_url, probe_cc=["TG"], timestamp=timestamp + clickhouse_url=db.clickhouse_url, probe_cc=["TG"], timestamp=timestamp, slack_webhook=None ) ) res = db.execute("SELECT COUNT() FROM event_detector_changepoints") From eed8aaae786dd4d71a72af04f62d9cdf6c4eeb52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Thu, 25 Jun 2026 14:16:31 +0200 Subject: [PATCH 06/13] Add explorer base url parameter --- dags/pipeline.py | 17 ++++++++++++++--- .../src/oonipipeline/analysis/detector.py | 16 +++++++++++----- oonipipeline/src/oonipipeline/tasks/detector.py | 6 ++++-- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/dags/pipeline.py b/dags/pipeline.py index e5a9c41b..17d78f25 100644 --- a/dags/pipeline.py +++ b/dags/pipeline.py @@ -63,7 +63,12 @@ def run_make_analysis( def run_make_event_detector( - clickhouse_url: str, probe_cc: List[str], timestamp: str = "", ts: str = "", slack_webhook : str | None = None + clickhouse_url: str, + probe_cc: List[str], + timestamp: str = "", + ts: str = "", + slack_webhook: str | None = None, + explorer_base_url: str = "https://explorer.ooni.org/", ): from oonipipeline.tasks.detector import MakeDetectorParams, make_detector @@ -71,8 +76,11 @@ def run_make_event_detector( timestamp = ts[:13] params = MakeDetectorParams( - clickhouse_url=clickhouse_url, probe_cc=probe_cc, timestamp=timestamp, - slack_webhook=slack_webhook + clickhouse_url=clickhouse_url, + probe_cc=probe_cc, + timestamp=timestamp, + slack_webhook=slack_webhook, + explorer_base_url=explorer_base_url, ) make_detector(params) @@ -231,6 +239,9 @@ def run_make_time_inconsistencies_analysis( "probe_cc": dag_full.params["probe_cc"], "clickhouse_url": Variable.get("clickhouse_url", default_var=""), "slack_webhook": Variable.get("slack_webhook", default_var=None), + "explorer_base_url": Variable.get( + "explorer_base_url", default_var="https://explorer.ooni.org/" + ), "ts": "{{ts}}", }, requirements=REQUIREMENTS, diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index bf72858d..04f1fb8f 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -508,7 +508,8 @@ def run_detector( gap_halflife: float = 48.0, warmup: bool = False, trace: bool = False, - slack_webhook: str | None = None + slack_webhook: str | None = None, + explorer_base_url: str = "https://explorer.ooni.org/" ) -> Tuple[List[Changepoint], List[LastCusum], List[CusumStep]]: db = ClickhouseClient.from_url(clickhouse_url) domains = get_domain_list(db) @@ -534,7 +535,7 @@ def run_detector( update_tables(db, updated_cusums, changepoints) if slack_webhook is not None: - notify_slack(db, changepoints, slack_webhook) + notify_slack(db, changepoints, slack_webhook, explorer_base_url) return changepoints, updated_cusums, steps @@ -655,7 +656,12 @@ def make_label(df, field, label, color): ) chart.show() -def notify_slack(db : ClickhouseClient, changepoints: list[Changepoint], slack_webhook : str): +def notify_slack( + db: ClickhouseClient, + changepoints: list[Changepoint], + slack_webhook: str, + explorer_base_url: str = "https://explorer.ooni.org/", +): """ Notifies that there was a change in blocking state of a relevant target """ @@ -672,8 +678,8 @@ def notify_slack(db : ClickhouseClient, changepoints: list[Changepoint], slack_w } messages = [] - for (i,cp) in enumerate(changepoints): - explorer = get_explorer_url(cp) + for (i, cp) in enumerate(changepoints): + explorer = get_explorer_url(cp, explorer_base_url) message += ( f"• :flag-{cp['probe_cc'].lower()}: [{cp['probe_cc']}/AS{cp['probe_asn']}] " f"*{cp['domain']}* {change_dir_str[cp['change_dir']]} - `{cp['block_type']}` " diff --git a/oonipipeline/src/oonipipeline/tasks/detector.py b/oonipipeline/src/oonipipeline/tasks/detector.py index 86aa7c70..d5d5d71f 100644 --- a/oonipipeline/src/oonipipeline/tasks/detector.py +++ b/oonipipeline/src/oonipipeline/tasks/detector.py @@ -10,7 +10,8 @@ class MakeDetectorParams: clickhouse_url: str probe_cc: List[str] timestamp: str - slack_webhook: str | None + slack_webhook: str | None = None + explorer_base_url: str = "https://explorer.ooni.org/" def make_detector(params: MakeDetectorParams): @@ -30,5 +31,6 @@ def make_detector(params: MakeDetectorParams): start_time=start_hour, end_time=end_hour, probe_cc=params.probe_cc, - slack_webhook=params.slack_webhook + slack_webhook=params.slack_webhook, + explorer_base_url=params.explorer_base_url, ) From 971942aef45de7723229d627c1a1014ef20c0a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Thu, 25 Jun 2026 14:18:17 +0200 Subject: [PATCH 07/13] remove unused clickhouse parameter --- oonipipeline/src/oonipipeline/analysis/detector.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 04f1fb8f..21d57f33 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -535,7 +535,7 @@ def run_detector( update_tables(db, updated_cusums, changepoints) if slack_webhook is not None: - notify_slack(db, changepoints, slack_webhook, explorer_base_url) + notify_slack( changepoints, slack_webhook, explorer_base_url) return changepoints, updated_cusums, steps @@ -657,7 +657,6 @@ def make_label(df, field, label, color): chart.show() def notify_slack( - db: ClickhouseClient, changepoints: list[Changepoint], slack_webhook: str, explorer_base_url: str = "https://explorer.ooni.org/", From d6d3c58d5851453cd994298412cbc249dad16dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Fri, 26 Jun 2026 11:30:42 +0200 Subject: [PATCH 08/13] Improve direction formatting --- .../src/oonipipeline/analysis/detector.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 21d57f33..414339e4 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -662,7 +662,8 @@ def notify_slack( explorer_base_url: str = "https://explorer.ooni.org/", ): """ - Notifies that there was a change in blocking state of a relevant target + Sends a message to slack with a list of all changepoints that were detected + in the last run """ if len(changepoints) == 0: @@ -670,18 +671,22 @@ def notify_slack( message = "*NEW EVENTS DETECTED*\n\nWe just detected the following blocking events:\n" - change_dir_str = { - -1 : "is unblocked :arrow_down: :large_green_circle:", - 0 : "0? :thinking_face:", - 1 : "is blocked :arrow_up: :red_circle:" - } + def dir_to_str(dir : int) -> str: + to_str = { + -1 : "is unblocked :arrow_down: :large_green_circle:", + 1 : "is blocked :arrow_up: :red_circle:" + } + return to_str.get( + dir, + f"Unknown direction change value: {dir} :thinking_face:" + ) messages = [] for (i, cp) in enumerate(changepoints): explorer = get_explorer_url(cp, explorer_base_url) message += ( f"• :flag-{cp['probe_cc'].lower()}: [{cp['probe_cc']}/AS{cp['probe_asn']}] " - f"*{cp['domain']}* {change_dir_str[cp['change_dir']]} - `{cp['block_type']}` " + f"*{cp['domain']}* {dir_to_str(cp['change_dir'])} - `{cp['block_type']}` " f"| <{explorer}|explorer>\n" ) From 46b07fc29b53f02ba3b0ae052d3634c9f5f41ac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Fri, 26 Jun 2026 12:15:35 +0200 Subject: [PATCH 09/13] Black reformat --- .../src/oonipipeline/analysis/detector.py | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 414339e4..bb93d8ba 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -509,7 +509,7 @@ def run_detector( warmup: bool = False, trace: bool = False, slack_webhook: str | None = None, - explorer_base_url: str = "https://explorer.ooni.org/" + explorer_base_url: str = "https://explorer.ooni.org/", ) -> Tuple[List[Changepoint], List[LastCusum], List[CusumStep]]: db = ClickhouseClient.from_url(clickhouse_url) domains = get_domain_list(db) @@ -535,7 +535,7 @@ def run_detector( update_tables(db, updated_cusums, changepoints) if slack_webhook is not None: - notify_slack( changepoints, slack_webhook, explorer_base_url) + notify_slack(changepoints, slack_webhook, explorer_base_url) return changepoints, updated_cusums, steps @@ -656,6 +656,7 @@ def make_label(df, field, label, color): ) chart.show() + def notify_slack( changepoints: list[Changepoint], slack_webhook: str, @@ -669,20 +670,19 @@ def notify_slack( if len(changepoints) == 0: return - message = "*NEW EVENTS DETECTED*\n\nWe just detected the following blocking events:\n" + message = ( + "*NEW EVENTS DETECTED*\n\nWe just detected the following blocking events:\n" + ) - def dir_to_str(dir : int) -> str: + def dir_to_str(dir: int) -> str: to_str = { - -1 : "is unblocked :arrow_down: :large_green_circle:", - 1 : "is blocked :arrow_up: :red_circle:" + -1: "is unblocked :arrow_down: :large_green_circle:", + 1: "is blocked :arrow_up: :red_circle:", } - return to_str.get( - dir, - f"Unknown direction change value: {dir} :thinking_face:" - ) + return to_str.get(dir, f"Unknown direction change value: {dir} :thinking_face:") messages = [] - for (i, cp) in enumerate(changepoints): + for i, cp in enumerate(changepoints): explorer = get_explorer_url(cp, explorer_base_url) message += ( f"• :flag-{cp['probe_cc'].lower()}: [{cp['probe_cc']}/AS{cp['probe_asn']}] " @@ -691,7 +691,7 @@ def dir_to_str(dir : int) -> str: ) # Send messages in 10 entries batches to avoid max message size limit - if (i+1) % 10 == 0: + if (i + 1) % 10 == 0: messages.append(message) message = "" @@ -702,14 +702,16 @@ def dir_to_str(dir : int) -> str: for msg in messages: send_to_slack(slack_webhook, msg) -def send_to_slack(webhook : str, message: str): - requests.post(webhook, json = { - "text" : message - }).raise_for_status() -def get_explorer_url(changepoint: Changepoint, base_url: str = "https://explorer.ooni.org/") -> str: - start_time = changepoint['ts'] - timedelta(days=5) - end_time = changepoint['ts'] + timedelta(days=5) +def send_to_slack(webhook: str, message: str): + requests.post(webhook, json={"text": message}).raise_for_status() + + +def get_explorer_url( + changepoint: Changepoint, base_url: str = "https://explorer.ooni.org/" +) -> str: + start_time = changepoint["ts"] - timedelta(days=5) + end_time = changepoint["ts"] + timedelta(days=5) def to_s(dt: datetime): return datetime.strftime(dt, "%Y-%m-%d") @@ -717,9 +719,9 @@ def to_s(dt: datetime): from urllib.parse import urlencode params = { - "domain": changepoint['domain'], - "probe_cc": changepoint['probe_cc'], - "probe_asn": changepoint['probe_asn'], + "domain": changepoint["domain"], + "probe_cc": changepoint["probe_cc"], + "probe_asn": changepoint["probe_asn"], "since": to_s(start_time), "until": to_s(end_time), "axis_x": "measurement_start_day", From b6e20e860f558b8f2bf3d2f9be978c8b8f4778f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Fri, 26 Jun 2026 12:17:27 +0200 Subject: [PATCH 10/13] Move import to top of the file --- oonipipeline/src/oonipipeline/analysis/detector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index bb93d8ba..8aef020a 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -3,6 +3,7 @@ from enum import Enum from itertools import groupby as itertools_groupby from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple +from urllib.parse import urlencode import requests from clickhouse_driver import Client as ClickhouseClient @@ -716,7 +717,6 @@ def get_explorer_url( def to_s(dt: datetime): return datetime.strftime(dt, "%Y-%m-%d") - from urllib.parse import urlencode params = { "domain": changepoint["domain"], From 9481db23f4190c7e2033d4c889db10138d324af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Fri, 26 Jun 2026 12:18:03 +0200 Subject: [PATCH 11/13] Remove extra empty line --- oonipipeline/src/oonipipeline/analysis/detector.py | 1 - 1 file changed, 1 deletion(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 8aef020a..1b26dbfe 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -717,7 +717,6 @@ def get_explorer_url( def to_s(dt: datetime): return datetime.strftime(dt, "%Y-%m-%d") - params = { "domain": changepoint["domain"], "probe_cc": changepoint["probe_cc"], From 71e750fed35255a4fceded36ee982da68bca64c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Mon, 29 Jun 2026 16:18:07 +0200 Subject: [PATCH 12/13] Set initial time to a lower value --- oonipipeline/src/oonipipeline/analysis/detector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 1b26dbfe..77b9aeb1 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -711,8 +711,8 @@ def send_to_slack(webhook: str, message: str): def get_explorer_url( changepoint: Changepoint, base_url: str = "https://explorer.ooni.org/" ) -> str: - start_time = changepoint["ts"] - timedelta(days=5) - end_time = changepoint["ts"] + timedelta(days=5) + start_time = changepoint["ts"] - timedelta(days=13) + end_time = changepoint["ts"] + timedelta(days=2) def to_s(dt: datetime): return datetime.strftime(dt, "%Y-%m-%d") From b993e51045a46729ec2a7b0efd203b6df2c14540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Mon, 29 Jun 2026 16:43:53 +0200 Subject: [PATCH 13/13] Add alerts page link to alert message --- .../src/oonipipeline/analysis/detector.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 77b9aeb1..3dbec55e 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -685,10 +685,12 @@ def dir_to_str(dir: int) -> str: messages = [] for i, cp in enumerate(changepoints): explorer = get_explorer_url(cp, explorer_base_url) + # Alerts panel not yet deployed to prod, we use the test one for now + alerts = get_alert_page_url(cp, "https://explorer.test.ooni.org/") message += ( f"• :flag-{cp['probe_cc'].lower()}: [{cp['probe_cc']}/AS{cp['probe_asn']}] " f"*{cp['domain']}* {dir_to_str(cp['change_dir'])} - `{cp['block_type']}` " - f"| <{explorer}|explorer>\n" + f"| <{explorer}|explorer> | <{alerts}|alerts>\n" ) # Send messages in 10 entries batches to avoid max message size limit @@ -727,3 +729,22 @@ def to_s(dt: datetime): } url = f"{base_url}/chart/mat?{urlencode(params)}" return url + +def get_alert_page_url( + changepoint: Changepoint, base_url: str = "https://explorer.ooni.org/" +) -> str: + start_time = changepoint["ts"] - timedelta(days=13) + end_time = changepoint["ts"] + timedelta(days=2) + + def to_s(dt: datetime): + return datetime.strftime(dt, "%Y-%m-%d") + + params = { + "domain": changepoint["domain"], + "probe_cc": changepoint["probe_cc"], + "probe_asn": changepoint["probe_asn"], + "since": to_s(start_time), + "until": to_s(end_time), + } + url = f"{base_url}/chart/alerts?{urlencode(params)}" + return url