nahiarhdlog 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.
- nahiarhdlog-0.1.0/LICENSE +21 -0
- nahiarhdlog-0.1.0/MANIFEST.in +3 -0
- nahiarhdlog-0.1.0/PKG-INFO +198 -0
- nahiarhdlog-0.1.0/README.md +160 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/__init__.py +20 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/adapters/__init__.py +0 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/adapters/fastapi.py +75 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/alerter.py +196 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/collector.py +127 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/dashboard/__init__.py +0 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/dashboard/router.py +152 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/dashboard/static/app.js +342 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/dashboard/static/index.html +116 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/dashboard/static/lock.html +51 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/dashboard/static/styles.css +237 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/grouping.py +32 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/handler.py +99 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/metrics.py +74 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/middleware.py +122 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/otel.py +92 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/py.typed +0 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/query.py +86 -0
- nahiarhdlog-0.1.0/nahiarhdLOG/storage.py +279 -0
- nahiarhdlog-0.1.0/nahiarhdlog.egg-info/PKG-INFO +198 -0
- nahiarhdlog-0.1.0/nahiarhdlog.egg-info/SOURCES.txt +37 -0
- nahiarhdlog-0.1.0/nahiarhdlog.egg-info/dependency_links.txt +1 -0
- nahiarhdlog-0.1.0/nahiarhdlog.egg-info/requires.txt +12 -0
- nahiarhdlog-0.1.0/nahiarhdlog.egg-info/top_level.txt +1 -0
- nahiarhdlog-0.1.0/pyproject.toml +63 -0
- nahiarhdlog-0.1.0/setup.cfg +4 -0
- nahiarhdlog-0.1.0/tests/test_adapter_boundary.py +45 -0
- nahiarhdlog-0.1.0/tests/test_alerter.py +220 -0
- nahiarhdlog-0.1.0/tests/test_dashboard.py +176 -0
- nahiarhdlog-0.1.0/tests/test_grouping.py +31 -0
- nahiarhdlog-0.1.0/tests/test_handler.py +97 -0
- nahiarhdlog-0.1.0/tests/test_metrics.py +89 -0
- nahiarhdlog-0.1.0/tests/test_middleware.py +106 -0
- nahiarhdlog-0.1.0/tests/test_otel.py +54 -0
- nahiarhdlog-0.1.0/tests/test_storage.py +123 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raihan Hidayatullah Djunaedi
|
|
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,198 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nahiarhdlog
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Embedded observability for FastAPI: searchable logs, error tracking, alerts, metrics, and tracing
|
|
5
|
+
Author-email: Raihan Hidayatullah Djunaedi <raihanhd.dev@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nahiarhd/nahiarhdlog
|
|
8
|
+
Project-URL: Documentation, https://github.com/nahiarhd/nahiarhdlog#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/nahiarhd/nahiarhdlog
|
|
10
|
+
Project-URL: Issues, https://github.com/nahiarhd/nahiarhdlog/issues
|
|
11
|
+
Keywords: fastapi,logging,observability,monitoring,error-tracking,tracing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: System :: Logging
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Classifier: Framework :: FastAPI
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: fastapi>=0.100
|
|
28
|
+
Requires-Dist: httpx>=0.24.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: uvicorn>=0.24.0; extra == "dev"
|
|
34
|
+
Provides-Extra: otel
|
|
35
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
|
|
36
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == "otel"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# nahiarhdlog
|
|
40
|
+
|
|
41
|
+
[](https://pypi.org/project/nahiarhdlog/)
|
|
42
|
+
[](https://pypi.org/project/nahiarhdlog/)
|
|
43
|
+
[](https://opensource.org/licenses/MIT)
|
|
44
|
+
|
|
45
|
+
Embedded observability for FastAPI: searchable logs, error tracking, alerts, metrics, and tracing — with zero-config defaults.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from fastapi import FastAPI
|
|
49
|
+
from nahiarhdLOG import observe
|
|
50
|
+
|
|
51
|
+
app = FastAPI()
|
|
52
|
+
observe(app, dashboard_token="secret") # logs + errors + metrics + dashboard
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
> **Status:** FastAPI is supported first. The core is framework-agnostic, so Flask/Django adapters can follow without changing it.
|
|
56
|
+
|
|
57
|
+
## 2-minute install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install nahiarhdlog
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires Python >= 3.10. No servers, no agents, no config files: events are stored in a local SQLite file (`nahiarhdlog.db`).
|
|
64
|
+
|
|
65
|
+
## Quick start
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import logging
|
|
69
|
+
from fastapi import FastAPI
|
|
70
|
+
from nahiarhdLOG import observe
|
|
71
|
+
|
|
72
|
+
app = FastAPI()
|
|
73
|
+
observe(app, dashboard_token="secret")
|
|
74
|
+
|
|
75
|
+
log = logging.getLogger(__name__)
|
|
76
|
+
|
|
77
|
+
@app.get("/users/{user_id}")
|
|
78
|
+
def get_user(user_id: int):
|
|
79
|
+
log.info("fetching user %d", user_id)
|
|
80
|
+
return {"id": user_id}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
That's it. Every request, every stdlib log record, and every uncaught exception is now captured. Open the dashboard at `/admin/logs` (enter the token on the lock screen).
|
|
84
|
+
|
|
85
|
+
## Features
|
|
86
|
+
|
|
87
|
+
| Area | What you get |
|
|
88
|
+
| ---- | ------------ |
|
|
89
|
+
| Logs | Auto-captured stdlib records + requests; full-text search (FTS5), level/type/time/trace filters, pagination |
|
|
90
|
+
| Errors | Grouped by `ExcType@file.py:line`, full tracebacks, top-errors ranking |
|
|
91
|
+
| Alerts | Threshold rules (N events in M seconds) + cooldown; webhook, Telegram, and SMTP sinks |
|
|
92
|
+
| Metrics | RPS, error rate, latency p50/p95, time-bucketed series — computed from the same request events |
|
|
93
|
+
| Tracing | One `trace_id` per request shared by logs and errors; timeline view; optional OpenTelemetry export |
|
|
94
|
+
| Dashboard | Embedded at `/admin/logs`, token-locked, dark/light mode, mobile-friendly, live tail |
|
|
95
|
+
|
|
96
|
+
Design limits (on purpose): bounded in-memory queue (full → drop + visible counter, requests are never blocked), batched async writes, paginated queries. Measured middleware overhead: p95 ~0.04 ms (see `scripts/probe_perf.py`).
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from nahiarhdLOG.alerter import Rule, TelegramSink, WebhookSink
|
|
102
|
+
|
|
103
|
+
observe(
|
|
104
|
+
app,
|
|
105
|
+
db_path="nahiarhdlog.db", # local SQLite file (WAL mode)
|
|
106
|
+
retention_days=7, # auto-purge older events
|
|
107
|
+
sample_rate=1.0, # 1.0 = every request; 500s are always kept
|
|
108
|
+
level=logging.INFO, # minimum stdlib level captured
|
|
109
|
+
rules=[Rule("errors", count=5, window_seconds=300, cooldown_seconds=3600)],
|
|
110
|
+
sinks=[TelegramSink(bot_token="...", chat_id="...")],
|
|
111
|
+
dashboard_prefix="/admin/logs",
|
|
112
|
+
dashboard_token="secret", # omit to disable the dashboard entirely
|
|
113
|
+
skip_paths=["/healthz"], # extra paths the middleware ignores
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The dashboard's own traffic is never logged, so live tail can't flood itself.
|
|
118
|
+
|
|
119
|
+
### Alert sinks
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from nahiarhdLOG.alerter import Rule, SmtpSink, TelegramSink, WebhookSink
|
|
123
|
+
|
|
124
|
+
rules = [Rule("api-errors", count=10, window_seconds=600, cooldown_seconds=1800)]
|
|
125
|
+
sinks = [
|
|
126
|
+
WebhookSink("https://hooks.example/deploy"), # POSTs {"subject","body"} as JSON
|
|
127
|
+
TelegramSink(bot_token="123:ABC", chat_id="-100..."), # Bot API sendMessage
|
|
128
|
+
SmtpSink("smtp.example.com", ["ops@example.com"], username="bot", password="..."),
|
|
129
|
+
]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Rules evaluate in a background thread; a failing sink never blocks the others.
|
|
133
|
+
|
|
134
|
+
### OpenTelemetry export (optional)
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pip install "nahiarhdlog[otel]"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from nahiarhdLOG.otel import export_trace
|
|
142
|
+
from nahiarhdLOG.query import get_trace
|
|
143
|
+
|
|
144
|
+
export_trace(get_trace(collector.storage, trace_id)) # keeps the original trace id
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Example app
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
# terminal 1: run the demo
|
|
151
|
+
uv run python examples/basic_app.py serve
|
|
152
|
+
|
|
153
|
+
# terminal 2: generate traffic
|
|
154
|
+
uv run python examples/basic_app.py traffic --n 300
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Open `http://127.0.0.1:8000/admin/logs` with token `demo-token`.
|
|
158
|
+
|
|
159
|
+
## FAQ
|
|
160
|
+
|
|
161
|
+
**Which Python versions?** 3.10+ (3.10, 3.11, 3.12 tested in CI).
|
|
162
|
+
|
|
163
|
+
**Where is data stored?** One SQLite file (`db_path`), WAL mode, FTS5 index for search. Delete the file to wipe everything. Set `retention_days` for automatic purging.
|
|
164
|
+
|
|
165
|
+
**Is the dashboard secure?** It is mounted only when `dashboard_token` is set, and every page + API call requires the token (query param or `Authorization: Bearer`). Without it you get a lock screen (401). Use a long random token and HTTPS in production.
|
|
166
|
+
|
|
167
|
+
**Why don't I see the dashboard's own requests in the logs?** By design: the dashboard prefix is auto-excluded so its polling doesn't drown your signal. Add more with `skip_paths`.
|
|
168
|
+
|
|
169
|
+
**What's the overhead?** Requests only enqueue a small dict; SQLite writes happen in batches on a background thread. Run `python scripts/probe_perf.py` to measure on your machine.
|
|
170
|
+
|
|
171
|
+
**Flask / Django / plain scripts?** On the roadmap. The core (`collector`, `storage`, `query`, `alerter`, `metrics`) imports no web framework — only the thin `adapters/` layer does, enforced by an automated boundary test.
|
|
172
|
+
|
|
173
|
+
**How do I disable the dashboard?** Omit `dashboard_token` (default): nothing is mounted.
|
|
174
|
+
|
|
175
|
+
## Roadmap
|
|
176
|
+
|
|
177
|
+
- [x] Core logging, SQLite+FTS5 storage, search API
|
|
178
|
+
- [x] Error tracker + webhook alerts
|
|
179
|
+
- [x] Metrics + tracing (+ optional OpenTelemetry export)
|
|
180
|
+
- [x] Embedded dashboard UI
|
|
181
|
+
- [ ] Flask/Django adapters
|
|
182
|
+
- [ ] Postgres storage backend
|
|
183
|
+
|
|
184
|
+
## Development
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
uv pip install -e ".[dev,otel]"
|
|
188
|
+
uv run --no-sync python -m pytest # run via python -m (see note below)
|
|
189
|
+
uv run --no-sync python scripts/probe_perf.py --n 10000
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
> Note (macOS quirk, this machine): something on this Mac re-applies the Finder's `hidden` flag to dot-directory contents, which makes CPython silently skip `.pth` files and breaks console-script entry points in `.venv`. Running tests via `python -m` (imports from the source tree) is immune, as are regular installs and CI. If a console script ever reports `ModuleNotFoundError`, run `chflags -R nohidden .venv` and prefer `python -m`.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT — see [LICENSE](LICENSE).
|
|
197
|
+
|
|
198
|
+
By [Raihan Hidayatullah Djunaedi](https://github.com/nahiarhd) · [nahiarhd.com](https://nahiarhd.com) · PyPI [@nahiarhd](https://pypi.org/user/nahiarhd/)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# nahiarhdlog
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/nahiarhdlog/)
|
|
4
|
+
[](https://pypi.org/project/nahiarhdlog/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
Embedded observability for FastAPI: searchable logs, error tracking, alerts, metrics, and tracing — with zero-config defaults.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
from nahiarhdLOG import observe
|
|
12
|
+
|
|
13
|
+
app = FastAPI()
|
|
14
|
+
observe(app, dashboard_token="secret") # logs + errors + metrics + dashboard
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
> **Status:** FastAPI is supported first. The core is framework-agnostic, so Flask/Django adapters can follow without changing it.
|
|
18
|
+
|
|
19
|
+
## 2-minute install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install nahiarhdlog
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Requires Python >= 3.10. No servers, no agents, no config files: events are stored in a local SQLite file (`nahiarhdlog.db`).
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import logging
|
|
31
|
+
from fastapi import FastAPI
|
|
32
|
+
from nahiarhdLOG import observe
|
|
33
|
+
|
|
34
|
+
app = FastAPI()
|
|
35
|
+
observe(app, dashboard_token="secret")
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
@app.get("/users/{user_id}")
|
|
40
|
+
def get_user(user_id: int):
|
|
41
|
+
log.info("fetching user %d", user_id)
|
|
42
|
+
return {"id": user_id}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
That's it. Every request, every stdlib log record, and every uncaught exception is now captured. Open the dashboard at `/admin/logs` (enter the token on the lock screen).
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
| Area | What you get |
|
|
50
|
+
| ---- | ------------ |
|
|
51
|
+
| Logs | Auto-captured stdlib records + requests; full-text search (FTS5), level/type/time/trace filters, pagination |
|
|
52
|
+
| Errors | Grouped by `ExcType@file.py:line`, full tracebacks, top-errors ranking |
|
|
53
|
+
| Alerts | Threshold rules (N events in M seconds) + cooldown; webhook, Telegram, and SMTP sinks |
|
|
54
|
+
| Metrics | RPS, error rate, latency p50/p95, time-bucketed series — computed from the same request events |
|
|
55
|
+
| Tracing | One `trace_id` per request shared by logs and errors; timeline view; optional OpenTelemetry export |
|
|
56
|
+
| Dashboard | Embedded at `/admin/logs`, token-locked, dark/light mode, mobile-friendly, live tail |
|
|
57
|
+
|
|
58
|
+
Design limits (on purpose): bounded in-memory queue (full → drop + visible counter, requests are never blocked), batched async writes, paginated queries. Measured middleware overhead: p95 ~0.04 ms (see `scripts/probe_perf.py`).
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from nahiarhdLOG.alerter import Rule, TelegramSink, WebhookSink
|
|
64
|
+
|
|
65
|
+
observe(
|
|
66
|
+
app,
|
|
67
|
+
db_path="nahiarhdlog.db", # local SQLite file (WAL mode)
|
|
68
|
+
retention_days=7, # auto-purge older events
|
|
69
|
+
sample_rate=1.0, # 1.0 = every request; 500s are always kept
|
|
70
|
+
level=logging.INFO, # minimum stdlib level captured
|
|
71
|
+
rules=[Rule("errors", count=5, window_seconds=300, cooldown_seconds=3600)],
|
|
72
|
+
sinks=[TelegramSink(bot_token="...", chat_id="...")],
|
|
73
|
+
dashboard_prefix="/admin/logs",
|
|
74
|
+
dashboard_token="secret", # omit to disable the dashboard entirely
|
|
75
|
+
skip_paths=["/healthz"], # extra paths the middleware ignores
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The dashboard's own traffic is never logged, so live tail can't flood itself.
|
|
80
|
+
|
|
81
|
+
### Alert sinks
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from nahiarhdLOG.alerter import Rule, SmtpSink, TelegramSink, WebhookSink
|
|
85
|
+
|
|
86
|
+
rules = [Rule("api-errors", count=10, window_seconds=600, cooldown_seconds=1800)]
|
|
87
|
+
sinks = [
|
|
88
|
+
WebhookSink("https://hooks.example/deploy"), # POSTs {"subject","body"} as JSON
|
|
89
|
+
TelegramSink(bot_token="123:ABC", chat_id="-100..."), # Bot API sendMessage
|
|
90
|
+
SmtpSink("smtp.example.com", ["ops@example.com"], username="bot", password="..."),
|
|
91
|
+
]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Rules evaluate in a background thread; a failing sink never blocks the others.
|
|
95
|
+
|
|
96
|
+
### OpenTelemetry export (optional)
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pip install "nahiarhdlog[otel]"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from nahiarhdLOG.otel import export_trace
|
|
104
|
+
from nahiarhdLOG.query import get_trace
|
|
105
|
+
|
|
106
|
+
export_trace(get_trace(collector.storage, trace_id)) # keeps the original trace id
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Example app
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# terminal 1: run the demo
|
|
113
|
+
uv run python examples/basic_app.py serve
|
|
114
|
+
|
|
115
|
+
# terminal 2: generate traffic
|
|
116
|
+
uv run python examples/basic_app.py traffic --n 300
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Open `http://127.0.0.1:8000/admin/logs` with token `demo-token`.
|
|
120
|
+
|
|
121
|
+
## FAQ
|
|
122
|
+
|
|
123
|
+
**Which Python versions?** 3.10+ (3.10, 3.11, 3.12 tested in CI).
|
|
124
|
+
|
|
125
|
+
**Where is data stored?** One SQLite file (`db_path`), WAL mode, FTS5 index for search. Delete the file to wipe everything. Set `retention_days` for automatic purging.
|
|
126
|
+
|
|
127
|
+
**Is the dashboard secure?** It is mounted only when `dashboard_token` is set, and every page + API call requires the token (query param or `Authorization: Bearer`). Without it you get a lock screen (401). Use a long random token and HTTPS in production.
|
|
128
|
+
|
|
129
|
+
**Why don't I see the dashboard's own requests in the logs?** By design: the dashboard prefix is auto-excluded so its polling doesn't drown your signal. Add more with `skip_paths`.
|
|
130
|
+
|
|
131
|
+
**What's the overhead?** Requests only enqueue a small dict; SQLite writes happen in batches on a background thread. Run `python scripts/probe_perf.py` to measure on your machine.
|
|
132
|
+
|
|
133
|
+
**Flask / Django / plain scripts?** On the roadmap. The core (`collector`, `storage`, `query`, `alerter`, `metrics`) imports no web framework — only the thin `adapters/` layer does, enforced by an automated boundary test.
|
|
134
|
+
|
|
135
|
+
**How do I disable the dashboard?** Omit `dashboard_token` (default): nothing is mounted.
|
|
136
|
+
|
|
137
|
+
## Roadmap
|
|
138
|
+
|
|
139
|
+
- [x] Core logging, SQLite+FTS5 storage, search API
|
|
140
|
+
- [x] Error tracker + webhook alerts
|
|
141
|
+
- [x] Metrics + tracing (+ optional OpenTelemetry export)
|
|
142
|
+
- [x] Embedded dashboard UI
|
|
143
|
+
- [ ] Flask/Django adapters
|
|
144
|
+
- [ ] Postgres storage backend
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
uv pip install -e ".[dev,otel]"
|
|
150
|
+
uv run --no-sync python -m pytest # run via python -m (see note below)
|
|
151
|
+
uv run --no-sync python scripts/probe_perf.py --n 10000
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
> Note (macOS quirk, this machine): something on this Mac re-applies the Finder's `hidden` flag to dot-directory contents, which makes CPython silently skip `.pth` files and breaks console-script entry points in `.venv`. Running tests via `python -m` (imports from the source tree) is immune, as are regular installs and CI. If a console script ever reports `ModuleNotFoundError`, run `chflags -R nohidden .venv` and prefer `python -m`.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT — see [LICENSE](LICENSE).
|
|
159
|
+
|
|
160
|
+
By [Raihan Hidayatullah Djunaedi](https://github.com/nahiarhd) · [nahiarhd.com](https://nahiarhd.com) · PyPI [@nahiarhd](https://pypi.org/user/nahiarhd/)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""nahiarhdlog: embedded observability for FastAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .collector import Collector
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__all__ = ["__version__", "Collector", "observe"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __getattr__(name: str) -> Any:
|
|
14
|
+
# Lazy so `import nahiarhdLOG` never requires FastAPI; only the
|
|
15
|
+
# framework adapter does.
|
|
16
|
+
if name == "observe":
|
|
17
|
+
from .adapters.fastapi import observe
|
|
18
|
+
|
|
19
|
+
return observe
|
|
20
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""FastAPI adapter: the only module (with the dashboard) allowed to
|
|
2
|
+
import FastAPI/Starlette. One call wires middleware + handler + excepthook."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..alerter import Alerter, AlertSink, Rule
|
|
10
|
+
from ..collector import Collector
|
|
11
|
+
from ..handler import NahiarhdHandler, install_excepthook
|
|
12
|
+
from ..middleware import LoggingMiddleware
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ensure_handler(collector: Collector, level: int) -> None:
|
|
16
|
+
root = logging.getLogger()
|
|
17
|
+
for h in root.handlers:
|
|
18
|
+
if isinstance(h, NahiarhdHandler) and h.collector is collector:
|
|
19
|
+
h.setLevel(level)
|
|
20
|
+
return
|
|
21
|
+
root.addHandler(NahiarhdHandler(collector, level=level))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def observe(
|
|
25
|
+
app: Any,
|
|
26
|
+
db_path: str = "nahiarhdlog.db",
|
|
27
|
+
retention_days: int = 7,
|
|
28
|
+
sample_rate: float = 1.0,
|
|
29
|
+
level: int = logging.INFO,
|
|
30
|
+
rules: list[Rule] | None = None,
|
|
31
|
+
sinks: list[AlertSink] | None = None,
|
|
32
|
+
dashboard_prefix: str = "/admin/logs",
|
|
33
|
+
dashboard_token: str | None = None,
|
|
34
|
+
skip_paths: list[str] | None = None,
|
|
35
|
+
) -> Collector:
|
|
36
|
+
"""Attach nahiarhdlog to a FastAPI app. Returns the collector.
|
|
37
|
+
|
|
38
|
+
The dashboard is mounted only when `dashboard_token` is set: it must
|
|
39
|
+
never be reachable without a key. The dashboard's own traffic is never
|
|
40
|
+
logged, so live tail can't flood itself.
|
|
41
|
+
"""
|
|
42
|
+
alerter = None
|
|
43
|
+
if rules and sinks:
|
|
44
|
+
alerter = Alerter(rules, sinks).start()
|
|
45
|
+
collector = Collector(
|
|
46
|
+
db_path, retention_days=retention_days, alerter=alerter
|
|
47
|
+
).start()
|
|
48
|
+
prefix = (dashboard_prefix or "/admin/logs").rstrip("/") or "/admin/logs"
|
|
49
|
+
skips = list(skip_paths or [])
|
|
50
|
+
if dashboard_token and prefix not in skips:
|
|
51
|
+
skips.append(prefix)
|
|
52
|
+
app.add_middleware(
|
|
53
|
+
LoggingMiddleware,
|
|
54
|
+
collector=collector,
|
|
55
|
+
sample_rate=sample_rate,
|
|
56
|
+
skip_prefixes=tuple(skips),
|
|
57
|
+
)
|
|
58
|
+
_ensure_handler(collector, level)
|
|
59
|
+
install_excepthook(collector)
|
|
60
|
+
if dashboard_token:
|
|
61
|
+
from ..dashboard.router import create_dashboard_router
|
|
62
|
+
|
|
63
|
+
app.include_router(
|
|
64
|
+
create_dashboard_router(collector, dashboard_token), prefix=prefix
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def _shutdown() -> None:
|
|
68
|
+
collector.stop()
|
|
69
|
+
if alerter is not None:
|
|
70
|
+
alerter.stop()
|
|
71
|
+
|
|
72
|
+
shutdown = getattr(getattr(app, "router", None), "on_shutdown", None)
|
|
73
|
+
if shutdown is not None:
|
|
74
|
+
shutdown.append(_shutdown)
|
|
75
|
+
return collector
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Threshold alerting with cooldown.
|
|
2
|
+
|
|
3
|
+
Framework-agnostic: this module must never import FastAPI/Starlette.
|
|
4
|
+
Windows are in-memory; a restart resets counters (documented behavior).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import queue
|
|
10
|
+
import smtplib
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections import deque
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from email.message import EmailMessage
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
_TELEGRAM_API = "https://api.telegram.org"
|
|
21
|
+
_HTTP_TIMEOUT = 10.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Rule:
|
|
26
|
+
"""Fire when `count` matching events arrive within `window_seconds`."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
count: int = 5
|
|
30
|
+
window_seconds: float = 300.0
|
|
31
|
+
cooldown_seconds: float = 3600.0
|
|
32
|
+
event_type: str = "error"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AlertSink:
|
|
36
|
+
def send(self, subject: str, body: str) -> None:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WebhookSink(AlertSink):
|
|
41
|
+
"""POST `{"subject": ..., "body": ...}` as JSON to any URL."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, url: str, headers: dict[str, str] | None = None) -> None:
|
|
44
|
+
self.url = url
|
|
45
|
+
self.headers = headers or {}
|
|
46
|
+
|
|
47
|
+
def send(self, subject: str, body: str) -> None:
|
|
48
|
+
resp = httpx.post(
|
|
49
|
+
self.url,
|
|
50
|
+
json={"subject": subject, "body": body},
|
|
51
|
+
headers=self.headers,
|
|
52
|
+
timeout=_HTTP_TIMEOUT,
|
|
53
|
+
)
|
|
54
|
+
resp.raise_for_status()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TelegramSink(AlertSink):
|
|
58
|
+
"""Send via Telegram Bot API `sendMessage`."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, bot_token: str, chat_id: str | int) -> None:
|
|
61
|
+
self.bot_token = bot_token
|
|
62
|
+
self.chat_id = chat_id
|
|
63
|
+
|
|
64
|
+
def send(self, subject: str, body: str) -> None:
|
|
65
|
+
text = f"{subject}\n{body}"[:4000]
|
|
66
|
+
resp = httpx.post(
|
|
67
|
+
f"{_TELEGRAM_API}/bot{self.bot_token}/sendMessage",
|
|
68
|
+
json={"chat_id": self.chat_id, "text": text},
|
|
69
|
+
timeout=_HTTP_TIMEOUT,
|
|
70
|
+
)
|
|
71
|
+
resp.raise_for_status()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SmtpSink(AlertSink):
|
|
75
|
+
"""Send via SMTP (stdlib only)."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
host: str,
|
|
80
|
+
to_addrs: list[str],
|
|
81
|
+
from_addr: str = "nahiarhdlog@localhost",
|
|
82
|
+
port: int = 587,
|
|
83
|
+
username: str | None = None,
|
|
84
|
+
password: str | None = None,
|
|
85
|
+
use_tls: bool = True,
|
|
86
|
+
) -> None:
|
|
87
|
+
self.host = host
|
|
88
|
+
self.port = port
|
|
89
|
+
self.to_addrs = to_addrs
|
|
90
|
+
self.from_addr = from_addr
|
|
91
|
+
self.username = username
|
|
92
|
+
self.password = password
|
|
93
|
+
self.use_tls = use_tls
|
|
94
|
+
|
|
95
|
+
def send(self, subject: str, body: str) -> None:
|
|
96
|
+
msg = EmailMessage()
|
|
97
|
+
msg["Subject"] = subject
|
|
98
|
+
msg["From"] = self.from_addr
|
|
99
|
+
msg["To"] = ", ".join(self.to_addrs)
|
|
100
|
+
msg.set_content(body)
|
|
101
|
+
with smtplib.SMTP(self.host, self.port, timeout=_HTTP_TIMEOUT) as smtp:
|
|
102
|
+
if self.use_tls:
|
|
103
|
+
smtp.starttls()
|
|
104
|
+
if self.username:
|
|
105
|
+
smtp.login(self.username, self.password or "")
|
|
106
|
+
smtp.send_message(msg)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class _RuleState:
|
|
111
|
+
hits: deque[float] = field(default_factory=deque)
|
|
112
|
+
last_fired: float = 0.0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Alerter:
|
|
116
|
+
"""Background evaluator: rules x sinks, never blocks the caller."""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
rules: list[Rule],
|
|
121
|
+
sinks: list[AlertSink],
|
|
122
|
+
queue_size: int = 1000,
|
|
123
|
+
clock: Callable[[], float] | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
self.rules = list(rules)
|
|
126
|
+
self.sinks = list(sinks)
|
|
127
|
+
self._clock = clock or time.monotonic
|
|
128
|
+
self._queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=queue_size)
|
|
129
|
+
self._states = {r.name: _RuleState() for r in self.rules}
|
|
130
|
+
self._stop = threading.Event()
|
|
131
|
+
self._started = False
|
|
132
|
+
self._thread = threading.Thread(
|
|
133
|
+
target=self._run, name="nahiarhdlog-alerter", daemon=True
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def start(self) -> Alerter:
|
|
137
|
+
if self._started:
|
|
138
|
+
return self
|
|
139
|
+
self._started = True
|
|
140
|
+
self._thread.start()
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def notify(self, event: dict[str, Any]) -> None:
|
|
144
|
+
try:
|
|
145
|
+
self._queue.put_nowait(event)
|
|
146
|
+
except queue.Full:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
def stop(self, timeout: float = 10.0) -> None:
|
|
150
|
+
if not self._started:
|
|
151
|
+
return
|
|
152
|
+
self._started = False
|
|
153
|
+
self._stop.set()
|
|
154
|
+
self._thread.join(timeout=timeout)
|
|
155
|
+
|
|
156
|
+
def _run(self) -> None:
|
|
157
|
+
while not self._stop.is_set():
|
|
158
|
+
try:
|
|
159
|
+
event = self._queue.get(timeout=0.2)
|
|
160
|
+
except queue.Empty:
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
self._process(event)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
def _process(self, event: dict[str, Any]) -> None:
|
|
168
|
+
now = self._clock()
|
|
169
|
+
for rule in self.rules:
|
|
170
|
+
if event.get("type") != rule.event_type:
|
|
171
|
+
continue
|
|
172
|
+
state = self._states[rule.name]
|
|
173
|
+
state.hits.append(now)
|
|
174
|
+
while state.hits and state.hits[0] <= now - rule.window_seconds:
|
|
175
|
+
state.hits.popleft()
|
|
176
|
+
if len(state.hits) < rule.count:
|
|
177
|
+
continue
|
|
178
|
+
if now - state.last_fired < rule.cooldown_seconds:
|
|
179
|
+
continue
|
|
180
|
+
state.last_fired = now
|
|
181
|
+
state.hits.clear()
|
|
182
|
+
self._fire(rule, event, now)
|
|
183
|
+
|
|
184
|
+
def _fire(self, rule: Rule, event: dict[str, Any], now: float) -> None:
|
|
185
|
+
data = event.get("data") or {}
|
|
186
|
+
sig = data.get("signature", "(unknown)")
|
|
187
|
+
subject = (
|
|
188
|
+
f"[nahiarhdlog] {rule.name}: "
|
|
189
|
+
f"{rule.count} errors in {rule.window_seconds:.0f}s"
|
|
190
|
+
)
|
|
191
|
+
body = f"Latest: {sig}\n{(event.get('message') or '')[:500]}"
|
|
192
|
+
for sink in self.sinks:
|
|
193
|
+
try:
|
|
194
|
+
sink.send(subject, body)
|
|
195
|
+
except Exception:
|
|
196
|
+
continue
|