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.
- watcherr-0.1.0/.gitignore +8 -0
- watcherr-0.1.0/LICENSE +21 -0
- watcherr-0.1.0/Makefile +26 -0
- watcherr-0.1.0/PKG-INFO +129 -0
- watcherr-0.1.0/README.md +96 -0
- watcherr-0.1.0/pyproject.toml +49 -0
- watcherr-0.1.0/tests/__init__.py +0 -0
- watcherr-0.1.0/tests/test_config.py +45 -0
- watcherr-0.1.0/tests/test_formatter.py +46 -0
- watcherr-0.1.0/tests/test_logging_handler.py +53 -0
- watcherr-0.1.0/tests/test_rate_limiter.py +27 -0
- watcherr-0.1.0/tests/test_sender.py +56 -0
- watcherr-0.1.0/watcherr/__init__.py +10 -0
- watcherr-0.1.0/watcherr/bot/__init__.py +0 -0
- watcherr-0.1.0/watcherr/bot/handlers.py +39 -0
- watcherr-0.1.0/watcherr/bot/main.py +34 -0
- watcherr-0.1.0/watcherr/config.py +46 -0
- watcherr-0.1.0/watcherr/formatter.py +43 -0
- watcherr-0.1.0/watcherr/integrations/__init__.py +0 -0
- watcherr-0.1.0/watcherr/integrations/celery_signals.py +27 -0
- watcherr-0.1.0/watcherr/integrations/fastapi_middleware.py +25 -0
- watcherr-0.1.0/watcherr/logging_handler.py +30 -0
- watcherr-0.1.0/watcherr/rate_limiter.py +38 -0
- watcherr-0.1.0/watcherr/sender.py +89 -0
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.
|
watcherr-0.1.0/Makefile
ADDED
|
@@ -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
|
watcherr-0.1.0/PKG-INFO
ADDED
|
@@ -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 |
|
watcherr-0.1.0/README.md
ADDED
|
@@ -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>") == "<script>"
|
|
46
|
+
assert _escape_html("a & b") == "a & 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()
|
|
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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)
|