diff --git a/solution/infrastructure/backend/.env.template b/solution/infrastructure/backend/.env.template
index 4338d62..e219fb4 100644
--- a/solution/infrastructure/backend/.env.template
+++ b/solution/infrastructure/backend/.env.template
@@ -18,3 +18,5 @@ MINIO_ENDPOINT=minio:9000
MINIO_CUSTOM_ENDPOINT_URL=http://127.0.0.1:13244
MINIO_ACCESS_KEY=admin
MINIO_SECRET_KEY=password
+DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN=6196898691:AAFucgj7ieEuYMvWG_MZAn0Ao3UBuHpvaVY
+DJANGO_NOTIFIER_TELEGRAM_CHAT_ID=-1002304409222
diff --git a/solution/services/backend/.env.template b/solution/services/backend/.env.template
index 5988a69..378245d 100644
--- a/solution/services/backend/.env.template
+++ b/solution/services/backend/.env.template
@@ -31,3 +31,12 @@ DJANGO_CREATE_SUPERUSER=False
DJANGO_SUPERUSER_USERNAME=
DJANGO_SUPERUSER_EMAIL=
DJANGO_SUPERUSER_PASSWORD=
+
+
+# Notifiers settings (only with DJANGO_DEBUG=False)
+
+# Telegram
+
+DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN=
+DJANGO_NOTIFIER_TELEGRAM_CHAT_ID=
+DJANGO_NOTIFIER_TELEGRAM_THREAD_ID=
diff --git a/solution/services/backend/config/notifiers/__init__.py b/solution/services/backend/config/notifiers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/solution/services/backend/config/notifiers/telegram.py b/solution/services/backend/config/notifiers/telegram.py
new file mode 100644
index 0000000..6ca3a65
--- /dev/null
+++ b/solution/services/backend/config/notifiers/telegram.py
@@ -0,0 +1,133 @@
+import datetime
+import logging
+import time
+import traceback
+from concurrent.futures import ThreadPoolExecutor
+
+import httpx
+from django.utils.timezone import get_current_timezone
+
+TELEGRAM_LOG_HANDLER = logging.getLogger("telegram_log_handler")
+
+LEVEL_EMOJIS = {
+ "DEBUG": "đ",
+ "INFO": "âšī¸",
+ "WARNING": "â ī¸",
+ "ERROR": "â",
+ "CRITICAL": "đ¨",
+}
+
+
+class LoggingHandler(logging.Handler):
+ _executor = ThreadPoolExecutor(max_workers=5)
+
+ def __init__(
+ self,
+ token: str,
+ chat_id: int,
+ thread_id: int | None = None,
+ retries: int | None = 3,
+ delay: int | None = 2,
+ timeout: int | None = 5,
+ ) -> None:
+ super().__init__()
+
+ self.token = token
+ self.chat_id = chat_id
+ self.thread_id = thread_id
+ self.retries = retries
+ self.delay = delay
+ self.timeout = timeout
+ self.api_url = f"https://api.telegram.org/bot{self.token}/sendMessage"
+
+ self.template = (
+ "{levelname}\n"
+ "\tGuid: {correlation_id}\n"
+ "\tTimestamp: {asctime}\n"
+ "\tLogger: {name}\n"
+ "\tFile: {pathname} "
+ "(Line: {lineno})\n\n"
+ '
\n' + ) + + def emit(self, record: logging.LogRecord) -> None: + try: + formatted_record = self.format(record) + self._executor.submit(self._send_message, formatted_record) + except Exception as e: # noqa: BLE001 + self.handleError(record) + TELEGRAM_LOG_HANDLER.exception(e) + + def _send_message(self, formatted_record: str) -> None: + payload = { + "chat_id": self.chat_id, + "text": formatted_record, + "parse_mode": "HTML", + } + if self.thread_id: + payload["reply_to_message_id"] = self.thread_id + + for attempt in range(1, self.retries + 1): + response = httpx.post( + self.api_url, + data=payload, + timeout=self.timeout, + ) + if response.status_code != httpx.codes.OK: + if attempt == self.retries: + TELEGRAM_LOG_HANDLER.exception( + "Failed to send to Telegram after %d attempts: %s", + self.retries, + response.text, + ) + else: + time.sleep(self.delay) + else: + return + + def format(self, record: logging.LogRecord) -> str: + try: + asctime = datetime.datetime.fromtimestamp( + record.created, + tz=get_current_timezone(), + ).strftime("%Y-%m-%d %H:%M:%S %Z") + level_emoji = LEVEL_EMOJIS.get(record.levelname, "") + + formatted_message = self.template.format( + levelname=f"{level_emoji} {record.levelname}", + correlation_id=getattr(record, "correlation_id", "N/A"), + asctime=asctime, + name=record.name, + pathname=record.pathname, + lineno=record.lineno, + message=record.getMessage(), + ) + + if record.exc_info: + formatted_message += self._format_exception(record.exc_info) + + formatted_message += ( + f"\n#{record.levelname.lower()} " + f"#{record.name.replace('.', '_')}" + ) + if hasattr(record, "correlation_id"): + formatted_message += f" #{record.correlation_id}" + except Exception as format_error: # noqa: BLE001 + TELEGRAM_LOG_HANDLER.exception( + "Error formatting log record: %s", + format_error, + ) + return f"Error formatting log record: {format_error}" + else: + return formatted_message + + @staticmethod + def _format_exception(exc_info: Exception) -> str: + exc_text = "".join(traceback.format_exception(*exc_info)) + return ( + f"\n
{exc_text}"
+ )
+
+ @classmethod
+ def shutdown_executor(cls) -> None:
+ cls._executor.shutdown(wait=True)
diff --git a/solution/services/backend/config/settings.py b/solution/services/backend/config/settings.py
index 140f1f1..122b846 100644
--- a/solution/services/backend/config/settings.py
+++ b/solution/services/backend/config/settings.py
@@ -300,6 +300,26 @@ USE_X_FORWARDED_PORT = False
WSGI_APPLICATION = "config.wsgi.application"
+# Notifiers
+
+# Telegram
+
+NOTIFIER_TELEGRAM_BOT_TOKEN = env(
+ "DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN",
+ default=None,
+)
+
+NOTIFIER_TELEGRAM_CHAT_ID = env(
+ "DJANGO_NOTIFIER_TELEGRAM_CHAT_ID",
+ default=None,
+)
+
+NOTIFIER_TELEGRAM_THREAD_ID = env(
+ "DJANGO_NOTIFIER_TELEGRAM_THREAD_ID",
+ default=None,
+)
+
+
# Logging
LOGGER_NAME = "adnova"
@@ -415,6 +435,24 @@ LOGGING = {
LOGGING_CONFIG = "logging.config.dictConfig"
+if NOTIFIER_TELEGRAM_BOT_TOKEN and NOTIFIER_TELEGRAM_CHAT_ID:
+ LOGGING_HANDLERS["telegram"] = {
+ "class": "config.notifiers.telegram.LoggingHandler",
+ "level": "INFO",
+ "filters": ["require_debug_false"],
+ "token": NOTIFIER_TELEGRAM_BOT_TOKEN,
+ "chat_id": NOTIFIER_TELEGRAM_CHAT_ID,
+ "thread_id": NOTIFIER_TELEGRAM_THREAD_ID,
+ "retries": 5,
+ "delay": 2,
+ "timeout": 5,
+ }
+ LOGGING_LOGGERS["django"]["handlers"].append("telegram")
+ LOGGING_LOGGERS["django.request"]["handlers"].append("telegram")
+ LOGGING_LOGGERS["health-check"]["handlers"].append("telegram")
+ LOGGING_LOGGERS[LOGGER_NAME]["handlers"].append("telegram")
+
+
# Models
ABSOLUTE_URL_OVERRIDES: dict[str, Callable] = {}