Skip to content

Commit 8580796

Browse files
feat: send errors via email
1 parent d5cbc73 commit 8580796

File tree

6 files changed

+105
-1
lines changed

6 files changed

+105
-1
lines changed

.env.test

+7
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,10 @@ BUCKET_REGION="test-region"
33
BUCKET_NAME="test-bucket"
44
BUCKET_ACCESS_KEY_ID="test-id"
55
BUCKET_ACCESS_KEY_SECRET="test-secret"
6+
7+
NOTIFICATIONS_ENABLED=1
8+
NOTIFICATIONS_SMTP_HOST="localhost"
9+
NOTIFICATIONS_SMTP_PORT=8025
10+
NOTIFICATIONS_SMTP_SSL_ENABLED=0
11+
NOTIFICATIONS_SENDER_EMAIL="[email protected]"
12+
NOTIFICATIONS_RECEIVER_EMAIL="[email protected]"

README.md

+11
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ For local development, create a file called `.env.local`, which will be used by
4444
You can change which file is loaded setting the environment variable `APP_ENV`.
4545
For example the tests set `APP_ENV=test`, which loads variables from `.env.test`.
4646

47+
### Email notification environment variables
48+
49+
To send failure notifications by email, the following environment variables must be set:
50+
51+
- `NOTIFICATIONS_ENABLED`: 1 to enable, 0 to disable
52+
- `NOTIFICATIONS_SMTP_HOST`
53+
- `NOTIFICATIONS_SMTP_PORT`
54+
- `NOTIFICATIONS_SMTP_SSL_ENABLED`: 1 to enable, 0 to disable
55+
- `NOTIFICATIONS_SENDER_EMAIL`
56+
- `NOTIFICATIONS_RECEIVER_EMAIL`
57+
4758
### Run app
4859

4960
```
+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import datetime
2+
import logging
3+
import os
4+
import smtplib
5+
import ssl
6+
from email.mime.multipart import MIMEMultipart
7+
from email.mime.text import MIMEText
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
def _send_email(errors: list[dict[str, str]]) -> None:
13+
SMTP_HOST = os.environ["NOTIFICATIONS_SMTP_HOST"]
14+
SMTP_PORT = int(os.environ["NOTIFICATIONS_SMTP_PORT"])
15+
SMTP_SSL_ENABLED = int(os.environ["NOTIFICATIONS_SMTP_SSL_ENABLED"])
16+
SENDER_EMAIL = os.environ["NOTIFICATIONS_SENDER_EMAIL"]
17+
RECEIVER_EMAIL = os.environ["NOTIFICATIONS_RECEIVER_EMAIL"]
18+
logger.info(f"Sending email to {RECEIVER_EMAIL}")
19+
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
20+
if SMTP_SSL_ENABLED:
21+
context = ssl.create_default_context()
22+
server.starttls(context=context)
23+
message = MIMEMultipart()
24+
message["Subject"] = "Errors in OC4IDS Datastore Pipeline run"
25+
message["From"] = SENDER_EMAIL
26+
message["To"] = RECEIVER_EMAIL
27+
28+
html = f"""\
29+
<h1>Errors in OC4IDS Datastore Pipeline run</h1>
30+
<p>The pipeline completed at {datetime.datetime.now(datetime.UTC)}.</p>
31+
<p>Please see errors for each dataset below:</p>
32+
{"".join([
33+
f"""
34+
<h2>{error["dataset_id"]}</h2>
35+
<p>Source URL: <code>{error["source_url"]}</code></p>
36+
<pre><code>{error["message"]}</code></pre>
37+
"""
38+
for error in errors
39+
])}
40+
"""
41+
message.attach(MIMEText(html, "html"))
42+
43+
server.sendmail(SENDER_EMAIL, RECEIVER_EMAIL, message.as_string())
44+
45+
46+
def send_notification(errors: list[dict[str, str]]) -> None:
47+
NOTIFICATIONS_ENABLED = bool(int(os.environ.get("NOTIFICATIONS_ENABLED", "0")))
48+
if NOTIFICATIONS_ENABLED:
49+
_send_email(errors)
50+
else:
51+
logger.info("Notifications are disabled, skipping")

oc4ids_datastore_pipeline/pipeline.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
get_dataset_ids,
1616
save_dataset,
1717
)
18+
from oc4ids_datastore_pipeline.notifications import send_notification
1819
from oc4ids_datastore_pipeline.registry import (
1920
fetch_registered_datasets,
2021
get_license_name_from_url,
@@ -165,11 +166,14 @@ def process_registry() -> None:
165166
process_dataset(dataset_id, url)
166167
except Exception as e:
167168
logger.warning(f"Failed to process dataset {dataset_id} with error {e}")
168-
errors.append({"dataset": dataset_id, "source_url": url, "errors": str(e)})
169+
errors.append(
170+
{"dataset_id": dataset_id, "source_url": url, "message": str(e)}
171+
)
169172
if errors:
170173
logger.error(
171174
f"Errors while processing registry: {json.dumps(errors, indent=4)}"
172175
)
176+
send_notification(errors)
173177
logger.info("Finished processing all datasets")
174178

175179

tests/test_notifications.py

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from unittest.mock import MagicMock
2+
3+
from pytest_mock import MockerFixture
4+
5+
from oc4ids_datastore_pipeline.notifications import send_notification
6+
7+
8+
def test_send_notification(mocker: MockerFixture) -> None:
9+
mock_smtp_server = MagicMock()
10+
patch_smtp = mocker.patch("oc4ids_datastore_pipeline.notifications.smtplib.SMTP")
11+
patch_smtp.return_value = mock_smtp_server
12+
13+
errors = [
14+
{
15+
"dataset_id": "test_dataset",
16+
"source_url": "https://test_dataset.json",
17+
"message": "Mocked exception",
18+
}
19+
]
20+
send_notification(errors)
21+
22+
patch_smtp.assert_called_once_with("localhost", 8025)
23+
with mock_smtp_server as server:
24+
server.sendmail.assert_called_once()
25+
sender, receiver, message = server.sendmail.call_args[0]
26+
assert sender == "[email protected]"
27+
assert receiver == "[email protected]"
28+
assert "test_dataset" in message
29+
assert "https://test_dataset.json" in message
30+
assert "Mocked exception" in message

tests/test_pipeline.py

+1
Original file line numberDiff line numberDiff line change
@@ -163,5 +163,6 @@ def test_process_registry_catches_exception(mocker: MockerFixture) -> None:
163163
"oc4ids_datastore_pipeline.pipeline.process_dataset"
164164
)
165165
patch_process_dataset.side_effect = Exception("Mocked exception")
166+
mocker.patch("oc4ids_datastore_pipeline.pipeline.send_notification")
166167

167168
process_registry()

0 commit comments

Comments
 (0)