watcherr 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ *.pyc
8
+ .env
watcherr-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 BEK Academy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,26 @@
1
+ .PHONY: install dev lint format test build publish clean
2
+
3
+ install:
4
+ pip install -e .
5
+
6
+ dev:
7
+ pip install -e ".[dev]"
8
+
9
+ lint:
10
+ ruff check watcherr/ tests/
11
+
12
+ format:
13
+ ruff format watcherr/ tests/
14
+ ruff check --fix watcherr/ tests/
15
+
16
+ test:
17
+ pytest tests/ -v
18
+
19
+ build:
20
+ python -m build
21
+
22
+ publish:
23
+ twine upload dist/*
24
+
25
+ clean:
26
+ rm -rf dist/ build/ *.egg-info
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: watcherr
3
+ Version: 0.1.0
4
+ Summary: Lightweight error alerting via Telegram
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: alerting,error,monitoring,telegram
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: System :: Monitoring
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: httpx>=0.24
15
+ Provides-Extra: all
16
+ Requires-Dist: aiogram>=3.0; extra == 'all'
17
+ Requires-Dist: celery>=5.0; extra == 'all'
18
+ Requires-Dist: fastapi>=0.100; extra == 'all'
19
+ Provides-Extra: bot
20
+ Requires-Dist: aiogram>=3.0; extra == 'bot'
21
+ Provides-Extra: celery
22
+ Requires-Dist: celery>=5.0; extra == 'celery'
23
+ Provides-Extra: dev
24
+ Requires-Dist: aiogram>=3.0; extra == 'dev'
25
+ Requires-Dist: celery>=5.0; extra == 'dev'
26
+ Requires-Dist: fastapi>=0.100; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
28
+ Requires-Dist: pytest>=7.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.4; extra == 'dev'
30
+ Provides-Extra: fastapi
31
+ Requires-Dist: fastapi>=0.100; extra == 'fastapi'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # watcherr
35
+
36
+ Lightweight error alerting via Telegram for Python apps.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install watcherr
42
+ ```
43
+
44
+ Optional integrations:
45
+
46
+ ```bash
47
+ pip install watcherr[fastapi]
48
+ pip install watcherr[celery]
49
+ pip install watcherr[all]
50
+ ```
51
+
52
+ ## Setup
53
+
54
+ ### 1. Get chat ID
55
+
56
+ ```bash
57
+ WATCHERR_BOT_TOKEN=<your-token> watcherr
58
+ ```
59
+
60
+ Send `/start` to the bot — it will reply with your `chat_id`.
61
+
62
+ ### 2. Configure
63
+
64
+ Via code:
65
+
66
+ ```python
67
+ import watcherr
68
+
69
+ watcherr.configure(
70
+ bot_token="123456:ABC-DEF",
71
+ chat_id="-1001234567890",
72
+ service_name="my-api",
73
+ )
74
+ ```
75
+
76
+ Or via `.env`:
77
+
78
+ ```
79
+ WATCHERR_BOT_TOKEN=123456:ABC-DEF
80
+ WATCHERR_CHAT_ID=-1001234567890
81
+ WATCHERR_SERVICE_NAME=my-api
82
+ WATCHERR_ENVIRONMENT=production
83
+ ```
84
+
85
+ ## Usage
86
+
87
+ ```python
88
+ import watcherr
89
+
90
+ watcherr.send_alert("Database connection failed", exc=exception)
91
+ watcherr.send_warning("Slow query", table="users", duration="5s")
92
+ watcherr.send_info("Deployed", version="1.2.0")
93
+ ```
94
+
95
+ ### Logging handler
96
+
97
+ ```python
98
+ import logging
99
+ from watcherr.logging_handler import WatcherrHandler
100
+
101
+ logging.getLogger("myapp").addHandler(WatcherrHandler())
102
+ ```
103
+
104
+ ### FastAPI middleware
105
+
106
+ ```python
107
+ from watcherr.integrations.fastapi_middleware import WatcherrMiddleware
108
+
109
+ app.add_middleware(WatcherrMiddleware)
110
+ ```
111
+
112
+ ### Celery signals
113
+
114
+ ```python
115
+ from watcherr.integrations.celery_signals import setup_celery_alerts
116
+
117
+ setup_celery_alerts()
118
+ ```
119
+
120
+ ## Config options
121
+
122
+ | Env variable | Default | Description |
123
+ |---|---|---|
124
+ | `WATCHERR_BOT_TOKEN` | — | Telegram bot token |
125
+ | `WATCHERR_CHAT_ID` | — | Telegram chat/group ID |
126
+ | `WATCHERR_SERVICE_NAME` | `app` | Service name in alert title |
127
+ | `WATCHERR_ENVIRONMENT` | `production` | Environment label |
128
+ | `WATCHERR_RATE_LIMIT` | `60` | Dedup window in seconds |
129
+ | `WATCHERR_ENABLED` | `true` | Enable/disable sending |
@@ -0,0 +1,96 @@
1
+ # watcherr
2
+
3
+ Lightweight error alerting via Telegram for Python apps.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install watcherr
9
+ ```
10
+
11
+ Optional integrations:
12
+
13
+ ```bash
14
+ pip install watcherr[fastapi]
15
+ pip install watcherr[celery]
16
+ pip install watcherr[all]
17
+ ```
18
+
19
+ ## Setup
20
+
21
+ ### 1. Get chat ID
22
+
23
+ ```bash
24
+ WATCHERR_BOT_TOKEN=<your-token> watcherr
25
+ ```
26
+
27
+ Send `/start` to the bot — it will reply with your `chat_id`.
28
+
29
+ ### 2. Configure
30
+
31
+ Via code:
32
+
33
+ ```python
34
+ import watcherr
35
+
36
+ watcherr.configure(
37
+ bot_token="123456:ABC-DEF",
38
+ chat_id="-1001234567890",
39
+ service_name="my-api",
40
+ )
41
+ ```
42
+
43
+ Or via `.env`:
44
+
45
+ ```
46
+ WATCHERR_BOT_TOKEN=123456:ABC-DEF
47
+ WATCHERR_CHAT_ID=-1001234567890
48
+ WATCHERR_SERVICE_NAME=my-api
49
+ WATCHERR_ENVIRONMENT=production
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```python
55
+ import watcherr
56
+
57
+ watcherr.send_alert("Database connection failed", exc=exception)
58
+ watcherr.send_warning("Slow query", table="users", duration="5s")
59
+ watcherr.send_info("Deployed", version="1.2.0")
60
+ ```
61
+
62
+ ### Logging handler
63
+
64
+ ```python
65
+ import logging
66
+ from watcherr.logging_handler import WatcherrHandler
67
+
68
+ logging.getLogger("myapp").addHandler(WatcherrHandler())
69
+ ```
70
+
71
+ ### FastAPI middleware
72
+
73
+ ```python
74
+ from watcherr.integrations.fastapi_middleware import WatcherrMiddleware
75
+
76
+ app.add_middleware(WatcherrMiddleware)
77
+ ```
78
+
79
+ ### Celery signals
80
+
81
+ ```python
82
+ from watcherr.integrations.celery_signals import setup_celery_alerts
83
+
84
+ setup_celery_alerts()
85
+ ```
86
+
87
+ ## Config options
88
+
89
+ | Env variable | Default | Description |
90
+ |---|---|---|
91
+ | `WATCHERR_BOT_TOKEN` | — | Telegram bot token |
92
+ | `WATCHERR_CHAT_ID` | — | Telegram chat/group ID |
93
+ | `WATCHERR_SERVICE_NAME` | `app` | Service name in alert title |
94
+ | `WATCHERR_ENVIRONMENT` | `production` | Environment label |
95
+ | `WATCHERR_RATE_LIMIT` | `60` | Dedup window in seconds |
96
+ | `WATCHERR_ENABLED` | `true` | Enable/disable sending |
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "watcherr"
7
+ version = "0.1.0"
8
+ description = "Lightweight error alerting via Telegram"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ keywords = ["error", "alerting", "telegram", "monitoring"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: System :: Monitoring",
19
+ ]
20
+
21
+ dependencies = [
22
+ "httpx>=0.24",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ fastapi = ["fastapi>=0.100"]
27
+ celery = ["celery>=5.0"]
28
+ bot = ["aiogram>=3.0"]
29
+ all = ["watcherr[fastapi,celery,bot]"]
30
+ dev = [
31
+ "watcherr[all]",
32
+ "pytest>=7.0",
33
+ "pytest-asyncio>=0.21",
34
+ "ruff>=0.4",
35
+ ]
36
+
37
+ [project.scripts]
38
+ watcherr = "watcherr.bot.main:main"
39
+
40
+ [tool.ruff]
41
+ target-version = "py310"
42
+ line-length = 120
43
+
44
+ [tool.ruff.lint]
45
+ select = ["E", "F", "I", "W"]
46
+
47
+ [tool.pytest.ini_options]
48
+ asyncio_mode = "auto"
49
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,45 @@
1
+ import os
2
+ from unittest.mock import patch
3
+
4
+ from watcherr.config import WatcherrConfig, configure
5
+
6
+
7
+ def test_default_config():
8
+ config = WatcherrConfig()
9
+ assert config.bot_token == ""
10
+ assert config.enabled is True
11
+ assert config.rate_limit_seconds == 60
12
+
13
+
14
+ def test_configure_explicit():
15
+ config = configure(
16
+ bot_token="123:ABC",
17
+ chat_id="-100",
18
+ service_name="test-svc",
19
+ environment="test",
20
+ rate_limit_seconds=30,
21
+ enabled=True,
22
+ )
23
+ assert config.bot_token == "123:ABC"
24
+ assert config.chat_id == "-100"
25
+ assert config.service_name == "test-svc"
26
+
27
+
28
+ def test_configure_from_env():
29
+ env = {
30
+ "WATCHERR_BOT_TOKEN": "env-token",
31
+ "WATCHERR_CHAT_ID": "-200",
32
+ "WATCHERR_SERVICE_NAME": "env-svc",
33
+ "WATCHERR_ENVIRONMENT": "staging",
34
+ "WATCHERR_RATE_LIMIT": "120",
35
+ "WATCHERR_ENABLED": "false",
36
+ }
37
+ with patch.dict(os.environ, env, clear=False):
38
+ config = configure()
39
+
40
+ assert config.bot_token == "env-token"
41
+ assert config.chat_id == "-200"
42
+ assert config.service_name == "env-svc"
43
+ assert config.environment == "staging"
44
+ assert config.rate_limit_seconds == 120
45
+ assert config.enabled is False
@@ -0,0 +1,46 @@
1
+ from watcherr.formatter import _escape_html, format_message
2
+
3
+
4
+ def test_format_error_message():
5
+ result = format_message(
6
+ level="error",
7
+ message="Something broke",
8
+ service_name="api",
9
+ environment="prod",
10
+ )
11
+ assert "ERROR" in result
12
+ assert "api" in result
13
+ assert "prod" in result
14
+ assert "Something broke" in result
15
+
16
+
17
+ def test_format_with_exception():
18
+ try:
19
+ raise ValueError("test error")
20
+ except ValueError as e:
21
+ result = format_message(
22
+ level="error",
23
+ message="Caught",
24
+ service_name="worker",
25
+ environment="staging",
26
+ exc=e,
27
+ )
28
+ assert "ValueError" in result
29
+ assert "test error" in result
30
+
31
+
32
+ def test_format_with_extra():
33
+ result = format_message(
34
+ level="warning",
35
+ message="Slow query",
36
+ service_name="db",
37
+ environment="prod",
38
+ extra={"table": "users", "duration": "5s"},
39
+ )
40
+ assert "table" in result
41
+ assert "users" in result
42
+
43
+
44
+ def test_escape_html():
45
+ assert _escape_html("<script>") == "&lt;script&gt;"
46
+ assert _escape_html("a & b") == "a &amp; b"
@@ -0,0 +1,53 @@
1
+ import logging
2
+ from unittest.mock import patch
3
+
4
+ from watcherr.config import configure
5
+ from watcherr.logging_handler import WatcherrHandler
6
+
7
+
8
+ def _setup():
9
+ configure(
10
+ bot_token="123:TEST",
11
+ chat_id="-999",
12
+ service_name="test",
13
+ rate_limit_seconds=0,
14
+ )
15
+
16
+
17
+ @patch("watcherr.logging_handler.send_alert")
18
+ def test_error_log_sends_alert(mock_send):
19
+ _setup()
20
+ logger = logging.getLogger("test.handler.error")
21
+ logger.addHandler(WatcherrHandler(level=logging.ERROR))
22
+ logger.setLevel(logging.ERROR)
23
+
24
+ logger.error("something failed")
25
+
26
+ mock_send.assert_called_once()
27
+ assert "something failed" in mock_send.call_args[0][0]
28
+
29
+
30
+ @patch("watcherr.logging_handler.send_warning")
31
+ def test_warning_log_sends_warning(mock_send):
32
+ _setup()
33
+ handler = WatcherrHandler(level=logging.WARNING)
34
+ logger = logging.getLogger("test.handler.warning")
35
+ logger.addHandler(handler)
36
+ logger.setLevel(logging.WARNING)
37
+
38
+ logger.warning("slow response")
39
+
40
+ mock_send.assert_called_once()
41
+
42
+
43
+ @patch("watcherr.logging_handler.send_alert")
44
+ def test_below_level_ignored(mock_send):
45
+ _setup()
46
+ handler = WatcherrHandler(level=logging.ERROR)
47
+ logger = logging.getLogger("test.handler.below")
48
+ logger.handlers = [handler]
49
+ logger.setLevel(logging.DEBUG)
50
+
51
+ logger.info("not important")
52
+
53
+ mock_send.assert_not_called()
@@ -0,0 +1,27 @@
1
+ import time
2
+
3
+ from watcherr.rate_limiter import RateLimiter
4
+
5
+
6
+ def test_first_message_allowed():
7
+ limiter = RateLimiter(window_seconds=60)
8
+ assert limiter.should_send("error: something") is True
9
+
10
+
11
+ def test_duplicate_blocked():
12
+ limiter = RateLimiter(window_seconds=60)
13
+ assert limiter.should_send("error: same") is True
14
+ assert limiter.should_send("error: same") is False
15
+
16
+
17
+ def test_different_messages_allowed():
18
+ limiter = RateLimiter(window_seconds=60)
19
+ assert limiter.should_send("error A") is True
20
+ assert limiter.should_send("error B") is True
21
+
22
+
23
+ def test_expired_message_allowed_again():
24
+ limiter = RateLimiter(window_seconds=0)
25
+ assert limiter.should_send("error: expire") is True
26
+ time.sleep(0.01)
27
+ assert limiter.should_send("error: expire") is True
@@ -0,0 +1,56 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ from watcherr.config import configure
4
+ from watcherr.sender import send_alert, send_info, send_warning
5
+
6
+
7
+ def _setup_config():
8
+ configure(
9
+ bot_token="123:TEST",
10
+ chat_id="-999",
11
+ service_name="test",
12
+ environment="test",
13
+ rate_limit_seconds=0,
14
+ )
15
+
16
+
17
+ @patch("watcherr.sender._dispatch")
18
+ def test_send_alert_dispatches(mock_dispatch: MagicMock):
19
+ _setup_config()
20
+ send_alert("test error")
21
+ mock_dispatch.assert_called_once()
22
+ text = mock_dispatch.call_args[0][0]
23
+ assert "ERROR" in text
24
+ assert "test error" in text
25
+
26
+
27
+ @patch("watcherr.sender._dispatch")
28
+ def test_send_warning_dispatches(mock_dispatch: MagicMock):
29
+ _setup_config()
30
+ send_warning("test warning")
31
+ mock_dispatch.assert_called_once()
32
+ text = mock_dispatch.call_args[0][0]
33
+ assert "WARNING" in text
34
+
35
+
36
+ @patch("watcherr.sender._dispatch")
37
+ def test_send_info_dispatches(mock_dispatch: MagicMock):
38
+ _setup_config()
39
+ send_info("deployed v1.2")
40
+ mock_dispatch.assert_called_once()
41
+ text = mock_dispatch.call_args[0][0]
42
+ assert "INFO" in text
43
+
44
+
45
+ @patch("watcherr.sender._dispatch")
46
+ def test_disabled_config_skips(mock_dispatch: MagicMock):
47
+ configure(bot_token="123:TEST", chat_id="-999", enabled=False)
48
+ send_alert("should not send")
49
+ mock_dispatch.assert_not_called()
50
+
51
+
52
+ @patch("watcherr.sender._dispatch")
53
+ def test_missing_token_skips(mock_dispatch: MagicMock):
54
+ configure(bot_token="", chat_id="-999")
55
+ send_alert("should not send")
56
+ mock_dispatch.assert_not_called()
@@ -0,0 +1,10 @@
1
+ from watcherr.config import WatcherrConfig, configure
2
+ from watcherr.sender import send_alert, send_info, send_warning
3
+
4
+ __all__ = [
5
+ "WatcherrConfig",
6
+ "configure",
7
+ "send_alert",
8
+ "send_warning",
9
+ "send_info",
10
+ ]
File without changes
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from aiogram import Router
4
+ from aiogram.filters import Command
5
+ from aiogram.types import Message
6
+
7
+ router = Router()
8
+
9
+
10
+ @router.message(Command("start"))
11
+ async def cmd_start(message: Message) -> None:
12
+ chat_id = message.chat.id
13
+ await message.answer(
14
+ f"<b>watcherr</b> bot activated.\n\n"
15
+ f"Your chat ID: <code>{chat_id}</code>\n\n"
16
+ f"Set this as <code>WATCHERR_CHAT_ID</code> in your environment.",
17
+ parse_mode="HTML",
18
+ )
19
+
20
+
21
+ @router.message(Command("status"))
22
+ async def cmd_status(message: Message) -> None:
23
+ await message.answer(
24
+ "<b>watcherr</b> is running and listening for alerts.",
25
+ parse_mode="HTML",
26
+ )
27
+
28
+
29
+ @router.message(Command("ping"))
30
+ async def cmd_ping(message: Message) -> None:
31
+ await message.answer("pong")
32
+
33
+
34
+ @router.message(Command("help"))
35
+ async def cmd_help(message: Message) -> None:
36
+ await message.answer(
37
+ "<b>Commands:</b>\n/start — Get chat ID\n/status — Check bot status\n/ping — Ping\n/help — This message",
38
+ parse_mode="HTML",
39
+ )
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import sys
6
+
7
+ from watcherr.bot.handlers import router
8
+
9
+
10
+ def main() -> None:
11
+ token = os.getenv("WATCHERR_BOT_TOKEN")
12
+ if not token:
13
+ print("Error: WATCHERR_BOT_TOKEN environment variable is required")
14
+ sys.exit(1)
15
+
16
+ asyncio.run(_run(token))
17
+
18
+
19
+ async def _run(token: str) -> None:
20
+ from aiogram import Bot, Dispatcher
21
+
22
+ bot = Bot(token=token)
23
+ dp = Dispatcher()
24
+ dp.include_router(router)
25
+
26
+ print(f"watcherr bot started (token: ...{token[-6:]})")
27
+ try:
28
+ await dp.start_polling(bot)
29
+ finally:
30
+ await bot.session.close()
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ _config: WatcherrConfig | None = None
7
+
8
+
9
+ @dataclass
10
+ class WatcherrConfig:
11
+ bot_token: str = ""
12
+ chat_id: str = ""
13
+ service_name: str = "app"
14
+ environment: str = "production"
15
+ rate_limit_seconds: int = 60
16
+ enabled: bool = True
17
+
18
+
19
+ def configure(
20
+ bot_token: str | None = None,
21
+ chat_id: str | None = None,
22
+ service_name: str | None = None,
23
+ environment: str | None = None,
24
+ rate_limit_seconds: int | None = None,
25
+ enabled: bool | None = None,
26
+ ) -> WatcherrConfig:
27
+ global _config
28
+
29
+ _config = WatcherrConfig(
30
+ bot_token=bot_token or os.getenv("WATCHERR_BOT_TOKEN", ""),
31
+ chat_id=chat_id or os.getenv("WATCHERR_CHAT_ID", ""),
32
+ service_name=service_name or os.getenv("WATCHERR_SERVICE_NAME", "app"),
33
+ environment=environment or os.getenv("WATCHERR_ENVIRONMENT", "production"),
34
+ rate_limit_seconds=(
35
+ rate_limit_seconds if rate_limit_seconds is not None else int(os.getenv("WATCHERR_RATE_LIMIT", "60"))
36
+ ),
37
+ enabled=enabled if enabled is not None else os.getenv("WATCHERR_ENABLED", "true").lower() == "true",
38
+ )
39
+ return _config
40
+
41
+
42
+ def get_config() -> WatcherrConfig:
43
+ global _config
44
+ if _config is None:
45
+ _config = configure()
46
+ return _config
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import traceback
4
+ from datetime import datetime, timezone
5
+
6
+ LEVEL_EMOJI = {
7
+ "error": "\U0001f534",
8
+ "warning": "\U0001f7e1",
9
+ "info": "\U0001f535",
10
+ }
11
+
12
+
13
+ def format_message(
14
+ level: str,
15
+ message: str,
16
+ service_name: str,
17
+ environment: str,
18
+ exc: BaseException | None = None,
19
+ extra: dict | None = None,
20
+ ) -> str:
21
+ emoji = LEVEL_EMOJI.get(level, "\u2753")
22
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
23
+ title = f"{emoji} <b>{level.upper()}</b> | <b>{service_name}</b> [{environment}]"
24
+
25
+ parts = [title, "", message]
26
+
27
+ if exc:
28
+ tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
29
+ if len(tb) > 2000:
30
+ tb = tb[:2000] + "\n... (truncated)"
31
+ parts.append(f"\n<pre>{_escape_html(tb)}</pre>")
32
+
33
+ if extra:
34
+ lines = [f" <b>{k}</b>: {_escape_html(str(v))}" for k, v in extra.items()]
35
+ parts.append("\n" + "\n".join(lines))
36
+
37
+ parts.append(f"\n<i>{now}</i>")
38
+
39
+ return "\n".join(parts)
40
+
41
+
42
+ def _escape_html(text: str) -> str:
43
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
File without changes
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from watcherr.sender import send_alert, send_warning
4
+
5
+
6
+ def setup_celery_alerts() -> None:
7
+ from celery.signals import task_failure, task_retry
8
+
9
+ @task_failure.connect
10
+ def on_task_failure(sender=None, task_id=None, exception=None, traceback=None, **kwargs):
11
+ task_name = sender.name if sender else "unknown"
12
+ send_alert(
13
+ f"Celery task failed: <b>{task_name}</b>",
14
+ exc=exception,
15
+ task_id=task_id or "",
16
+ task=task_name,
17
+ )
18
+
19
+ @task_retry.connect
20
+ def on_task_retry(sender=None, request=None, reason=None, **kwargs):
21
+ task_name = sender.name if sender else "unknown"
22
+ send_warning(
23
+ f"Celery task retrying: <b>{task_name}</b>",
24
+ reason=str(reason) if reason else "",
25
+ task_id=request.id if request else "",
26
+ task=task_name,
27
+ )
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from watcherr.sender import send_alert
4
+
5
+
6
+ def WatcherrMiddleware(app):
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+ from starlette.requests import Request
9
+ from starlette.responses import Response
10
+
11
+ class _Middleware(BaseHTTPMiddleware):
12
+ async def dispatch(self, request: Request, call_next) -> Response:
13
+ try:
14
+ return await call_next(request)
15
+ except Exception as exc:
16
+ send_alert(
17
+ f"Unhandled exception in {request.method} {request.url.path}",
18
+ exc=exc,
19
+ method=request.method,
20
+ path=request.url.path,
21
+ client=request.client.host if request.client else "unknown",
22
+ )
23
+ raise
24
+
25
+ return _Middleware(app)
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from watcherr.sender import send_alert, send_warning
6
+
7
+
8
+ class WatcherrHandler(logging.Handler):
9
+ def __init__(self, level: int = logging.ERROR):
10
+ super().__init__(level)
11
+
12
+ def emit(self, record: logging.LogRecord) -> None:
13
+ try:
14
+ message = self.format(record) if self.formatter else record.getMessage()
15
+ extra = {
16
+ "logger": record.name,
17
+ "module": record.module,
18
+ }
19
+
20
+ if record.funcName:
21
+ extra["function"] = record.funcName
22
+
23
+ exc = record.exc_info[1] if record.exc_info and record.exc_info[1] else None
24
+
25
+ if record.levelno >= logging.ERROR:
26
+ send_alert(message, exc=exc, **extra)
27
+ elif record.levelno >= logging.WARNING:
28
+ send_warning(message, exc=exc, **extra)
29
+ except Exception:
30
+ self.handleError(record)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import threading
5
+ import time
6
+
7
+
8
+ class RateLimiter:
9
+ def __init__(self, window_seconds: int = 60):
10
+ self._window = window_seconds
11
+ self._seen: dict[str, float] = {}
12
+ self._lock = threading.Lock()
13
+
14
+ def should_send(self, message: str) -> bool:
15
+ key = hashlib.md5(message.encode()).hexdigest()
16
+ now = time.monotonic()
17
+
18
+ with self._lock:
19
+ self._cleanup(now)
20
+ if key in self._seen:
21
+ return False
22
+ self._seen[key] = now
23
+ return True
24
+
25
+ def _cleanup(self, now: float) -> None:
26
+ expired = [k for k, t in self._seen.items() if now - t > self._window]
27
+ for k in expired:
28
+ del self._seen[k]
29
+
30
+
31
+ _limiter: RateLimiter | None = None
32
+
33
+
34
+ def get_rate_limiter(window: int) -> RateLimiter:
35
+ global _limiter
36
+ if _limiter is None or _limiter._window != window:
37
+ _limiter = RateLimiter(window)
38
+ return _limiter
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import threading
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from watcherr.config import WatcherrConfig, get_config
11
+ from watcherr.formatter import format_message
12
+ from watcherr.rate_limiter import get_rate_limiter
13
+
14
+ logger = logging.getLogger("watcherr")
15
+
16
+ TELEGRAM_API = "https://api.telegram.org/bot{token}/sendMessage"
17
+
18
+
19
+ def _send_sync(text: str, config: WatcherrConfig) -> bool:
20
+ url = TELEGRAM_API.format(token=config.bot_token)
21
+ try:
22
+ resp = httpx.post(url, json={"chat_id": config.chat_id, "text": text, "parse_mode": "HTML"}, timeout=10)
23
+ if not resp.is_success:
24
+ logger.warning("watcherr: telegram %s: %s", resp.status_code, resp.text[:200])
25
+ return resp.is_success
26
+ except Exception:
27
+ logger.debug("watcherr: send failed", exc_info=True)
28
+ return False
29
+
30
+
31
+ async def _send_async(text: str, config: WatcherrConfig) -> bool:
32
+ url = TELEGRAM_API.format(token=config.bot_token)
33
+ try:
34
+ async with httpx.AsyncClient() as client:
35
+ resp = await client.post(
36
+ url, json={"chat_id": config.chat_id, "text": text, "parse_mode": "HTML"}, timeout=10
37
+ )
38
+ if not resp.is_success:
39
+ logger.warning("watcherr: telegram %s: %s", resp.status_code, resp.text[:200])
40
+ return resp.is_success
41
+ except Exception:
42
+ logger.debug("watcherr: send failed", exc_info=True)
43
+ return False
44
+
45
+
46
+ def _dispatch(text: str) -> None:
47
+ config = get_config()
48
+ try:
49
+ loop = asyncio.get_running_loop()
50
+ except RuntimeError:
51
+ loop = None
52
+
53
+ if loop and loop.is_running():
54
+ loop.create_task(_send_async(text, config))
55
+ else:
56
+ threading.Thread(target=_send_sync, args=(text, config), daemon=True).start()
57
+
58
+
59
+ def _send(level: str, message: str, exc: BaseException | None = None, extra: dict | None = None) -> None:
60
+ config = get_config()
61
+ if not config.enabled or not config.bot_token or not config.chat_id:
62
+ return
63
+
64
+ text = format_message(
65
+ level=level,
66
+ message=message,
67
+ service_name=config.service_name,
68
+ environment=config.environment,
69
+ exc=exc,
70
+ extra=extra,
71
+ )
72
+
73
+ limiter = get_rate_limiter(config.rate_limit_seconds)
74
+ if not limiter.should_send(text):
75
+ return
76
+
77
+ _dispatch(text)
78
+
79
+
80
+ def send_alert(message: str, exc: BaseException | None = None, **extra: Any) -> None:
81
+ _send("error", message, exc=exc, extra=extra or None)
82
+
83
+
84
+ def send_warning(message: str, exc: BaseException | None = None, **extra: Any) -> None:
85
+ _send("warning", message, exc=exc, extra=extra or None)
86
+
87
+
88
+ def send_info(message: str, **extra: Any) -> None:
89
+ _send("info", message, extra=extra or None)