-
Notifications
You must be signed in to change notification settings - Fork 169
Alerting integration #542
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Alerting integration #542
Changes from 43 commits
4e189e1
6d70099
163aa25
df51251
c578ad9
176541b
bd83210
2baf6aa
22c129c
640db86
084a835
d4c06a5
98ffae8
e5c7232
8741d34
aae159f
7bfd1f9
365b957
81adf79
ea8fff4
29baf4c
40519eb
91a2d33
cc7b7c9
fa5f20e
2882e3f
58ec5ca
627494b
75b7d31
5de732f
fa43c82
0156a05
153fb95
ce89ca2
307a92f
8cf16e7
a2594d0
2a9b063
8c442c4
4fbfb75
e7a5d12
e117af2
fe1fba9
474fb7c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| # Alerts | ||
|
|
||
| ## Issue Creation | ||
|
|
||
| At the moment the secrets and variables for the issue are saved in a .env file | ||
|
|
||
| There is a template with file name `.env_template` that can be used to fill in the info needed for the issue creation alert to work. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't see such a file. And also, I see that the file above -- config.cfg -- already definies secrets and variables. So is this still needed? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No it is not needed and should be deleted. Was a part of previous setup. Now the config.cfg is used |
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know why but There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import importlib.resources | ||
| import os | ||
| import traceback | ||
| from datetime import datetime | ||
|
|
||
| from jinja2 import Environment, FileSystemLoader | ||
|
|
||
| import flask_monitoringdashboard | ||
| from flask_monitoringdashboard.core.config import Config | ||
|
|
||
| template_root = os.path.join(importlib.resources.files(flask_monitoringdashboard).__str__(), 'templates', 'alert') | ||
| template_env = Environment(loader=FileSystemLoader(template_root)) | ||
| template = template_env.get_template('report.html') | ||
|
|
||
|
|
||
| class AlertContent: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A small comment explaining the use of this class would be good |
||
|
|
||
| def __init__(self, exception: BaseException, config: Config): | ||
| self._exception = exception | ||
|
|
||
| self.created_at = datetime.now(config.timezone) | ||
| self.created_at_str = self.created_at.strftime('%Y-%m-%d %H:%M:%S') | ||
| self.send_at = None # When was the alert sent, None if not sent yet | ||
|
|
||
| self.exception_type = exception.__class__.__name__ | ||
|
|
||
| self.title = self._create_title() | ||
| self.stack_trace = ''.join(traceback.format_exception(type(exception), exception, exception.__traceback__)) | ||
|
|
||
| def _create_title(self) -> str: | ||
| return f"[{self.exception_type}] Uncaught exception at {self.created_at_str}" | ||
|
|
||
| def get_limited_stack_trace(self, char_limit: int | None) -> str: | ||
| if not char_limit: | ||
| return self.stack_trace | ||
| return self.stack_trace[:char_limit] + ('...' if len(self.stack_trace) > char_limit else '') | ||
|
|
||
| def create_body_text(self, char_limit: int | None) -> str: | ||
| return f"An exception of type {self.exception_type} \n\nStack Trace: {self.get_limited_stack_trace(char_limit)}" | ||
|
|
||
| def create_body_markdown(self, char_limit: int | None) -> str: | ||
| return ( | ||
| f"**Type:** `{self.exception_type}`\n" | ||
| f"**Timestamp:** `{self.created_at_str}`\n" | ||
| f"**Stack Trace:**\n```\n{self.get_limited_stack_trace(char_limit)}\n```" | ||
| ) | ||
|
|
||
| def create_body_mrkdwn(self, char_limit: int | None) -> str: | ||
| return ( | ||
| f"*Type:* `{self.exception_type}`\n" | ||
| f"*Timestamp:* `{self.created_at_str}`\n" | ||
| f"*Stack Trace:*\n```\n{self.get_limited_stack_trace(char_limit)}\n```" | ||
| ) | ||
|
|
||
| def create_body_html(self, char_limit: int | None) -> str: | ||
| return template.render( | ||
| exc_type=self.exception_type, | ||
| timestamp=self.created_at_str, | ||
| stacktrace=self.get_limited_stack_trace(char_limit) | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import requests | ||
|
|
||
| from flask_monitoringdashboard.core.alert.alert_content import AlertContent | ||
|
|
||
| SLACK_CHAR_LIMIT = 2750 | ||
| ROCKET_CHAT_CHAR_LIMIT = 4500 | ||
| TEAMS_CHAR_LIMIT = 5000 | ||
|
|
||
|
|
||
| def send_message(alert_content: AlertContent): | ||
| from flask_monitoringdashboard import config | ||
|
|
||
| payload_creators = { | ||
| "SLACK": create_slack_payload, | ||
| "ROCKET_CHAT": create_rocket_chat_payload, | ||
| "TEAMS": create_teams_payload, | ||
| } | ||
|
|
||
| creator = payload_creators.get(config.chat_platform) | ||
| if not creator: | ||
| print("Invalid chat platform.") | ||
| return | ||
|
|
||
| payload = creator(alert_content) | ||
|
|
||
| try: | ||
| resp = requests.post(config.chat_webhook_url, json=payload, timeout=5) | ||
| resp.raise_for_status() | ||
| except Exception as e: | ||
| print("Alert delivery failed:", e) | ||
|
|
||
|
|
||
| def create_slack_payload(alert_content: AlertContent): | ||
| return { | ||
| "blocks": [ | ||
| { | ||
| "type": "section", | ||
| "text": {"type": "mrkdwn", "text": alert_content.create_body_mrkdwn(SLACK_CHAR_LIMIT)} | ||
| } | ||
| ] | ||
| } | ||
|
|
||
|
|
||
| def create_rocket_chat_payload(alert_content: AlertContent): | ||
| return { | ||
| "text": alert_content.create_body_markdown(ROCKET_CHAT_CHAR_LIMIT), | ||
| } | ||
|
|
||
|
|
||
| def create_teams_payload(alert_content: AlertContent): | ||
| return { | ||
| "type": "message", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. haha. Why am I not surprised that the Microsoft alternative is the most complicated one? |
||
| "attachments": [ | ||
| { | ||
| "contentType": "application/vnd.microsoft.card.adaptive", | ||
| "content": { | ||
| "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", | ||
| "type": "AdaptiveCard", | ||
| "version": "1.4", | ||
| "body": [ | ||
| { | ||
| "type": "TextBlock", | ||
| "text": "Exception Alert", | ||
| "weight": "Bolder", | ||
| "size": "Large", | ||
| "color": "Attention" | ||
| }, | ||
| { | ||
| "type": "FactSet", | ||
| "facts": [ | ||
| { | ||
| "title": "Type:", | ||
| "value": f"{alert_content.exception_type}" | ||
| }, | ||
| { | ||
| "title": "Timestamp:", | ||
| "value": f"{alert_content.created_at_str}" | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "type": "TextBlock", | ||
| "text": "Stack Trace:", | ||
| "weight": "Bolder", | ||
| "wrap": True | ||
| }, | ||
| { | ||
| "type": "Container", | ||
| "id": "stackContainer", | ||
| "isVisible": True, | ||
| "items": [ | ||
| { | ||
| "type": "CodeBlock", | ||
| "codeSnippet": f"{alert_content.get_limited_stack_trace(TEAMS_CHAR_LIMIT)}", | ||
| "language": "bash", | ||
| "wrap": True, | ||
| "fontType": "Monospace", | ||
| "targetWidth": "Standard", | ||
| "fallback": { | ||
| "type": "TextBlock", | ||
| "text": f"{alert_content.get_limited_stack_trace(TEAMS_CHAR_LIMIT)}", | ||
| "wrap": True, | ||
| "fontType": "Monospace" | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import smtplib | ||
| from email.mime.multipart import MIMEMultipart | ||
| from email.mime.text import MIMEText | ||
|
|
||
| from flask_monitoringdashboard.core.alert.alert_content import AlertContent | ||
|
|
||
|
|
||
| def send_email(alert_content: AlertContent): | ||
| from flask_monitoringdashboard import config | ||
|
|
||
| message = MIMEMultipart('alternative') | ||
| message.set_charset('utf-8') | ||
| message['Subject'] = alert_content.title | ||
| message['From'] = config.smtp_user | ||
| message['To'] = ', '.join(config.smtp_to) | ||
|
|
||
| message.attach(MIMEText(alert_content.create_body_text(None), 'plain', 'utf-8')) | ||
| message.attach(MIMEText(alert_content.create_body_html(None), 'html', 'utf-8')) | ||
|
|
||
| try: | ||
| with smtplib.SMTP(config.smtp_host, int(config.smtp_port)) as smtp: | ||
| smtp.starttls() | ||
|
|
||
| if config.smtp_password: | ||
| smtp.login(config.smtp_user, config.smtp_password) | ||
|
|
||
| smtp.sendmail(config.smtp_user, config.smtp_to, message.as_string()) | ||
| except Exception as e: | ||
| print("Error sending email alert:", e) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| class GitHubRequestInfo: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you really need this class? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess we could do it without. Is that preferred? |
||
| """ | ||
| RequestInfo is a class that represents the information of a request. | ||
| """ | ||
|
|
||
| def __init__(self, github_token: str, repo_owner: str, repo_name: str): | ||
| """ | ||
| Initializes the RequestInfo object with the given parameters. | ||
| :param github_token: the PAT token that has access to the repository | ||
| :param repo_owner: The owner/organisation that contains the repository | ||
| :param repo_name: The name of the repository | ||
| """ | ||
| self.repo_owner = repo_owner | ||
| self.repo_name = repo_name | ||
| self.github_token = github_token | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import requests | ||
|
|
||
| from flask_monitoringdashboard.core.alert.alert_content import AlertContent | ||
| from .github_request_info import GitHubRequestInfo | ||
|
|
||
| GITHUB_CHAR_LIMIT = 60000 | ||
|
|
||
|
|
||
| def get_base_repo_url(repo_owner: str, repo_name: str): | ||
| return f"https://api.github.com/repos/{repo_owner}/{repo_name}/" | ||
|
|
||
|
|
||
| def get_issue_url(repo_owner: str, repo_name: str): | ||
| return get_base_repo_url(repo_owner, repo_name) + "issues" | ||
|
|
||
|
|
||
| def get_endpoint_url(repo_owner: str, repo_name: str, endpoint: str): | ||
| return f"https://api.github.com/repos/{repo_owner}/{repo_name}/{endpoint}" | ||
|
|
||
|
|
||
| def make_post_request(request_info: GitHubRequestInfo, endpoint: str, data): | ||
| url = get_endpoint_url(request_info.repo_owner, request_info.repo_name, endpoint) | ||
| headers = _post_headers(request_info.github_token) | ||
|
|
||
| return requests.post(url, headers=headers, json=data) | ||
|
|
||
|
|
||
| def create_issue( | ||
| request_info: GitHubRequestInfo, | ||
| alert_content: AlertContent) -> requests.Response: | ||
| data = { | ||
| "title": alert_content.title, | ||
| "body": alert_content.create_body_markdown(GITHUB_CHAR_LIMIT), | ||
| "labels": ["automated-issue", "exception"], | ||
| } | ||
|
|
||
| return make_post_request(request_info, "issues", data) | ||
|
|
||
|
|
||
| def _post_headers(github_token: str): | ||
| headers = { | ||
| "Authorization": f"Bearer {github_token}", | ||
| "Accept": "application/vnd.github.v3+json" | ||
| } | ||
| return headers | ||
|
|
||
|
|
||
| def main(): | ||
| print("This is a utility file with helper functions not to be run directly.") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really need the
TYPE? We can simply test in the code for the existence of the ENVVARS? Or is there a case where we want to have theTYPEdefined independently from the enviers?