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.
Files changed (39) hide show
  1. nahiarhdlog-0.1.0/LICENSE +21 -0
  2. nahiarhdlog-0.1.0/MANIFEST.in +3 -0
  3. nahiarhdlog-0.1.0/PKG-INFO +198 -0
  4. nahiarhdlog-0.1.0/README.md +160 -0
  5. nahiarhdlog-0.1.0/nahiarhdLOG/__init__.py +20 -0
  6. nahiarhdlog-0.1.0/nahiarhdLOG/adapters/__init__.py +0 -0
  7. nahiarhdlog-0.1.0/nahiarhdLOG/adapters/fastapi.py +75 -0
  8. nahiarhdlog-0.1.0/nahiarhdLOG/alerter.py +196 -0
  9. nahiarhdlog-0.1.0/nahiarhdLOG/collector.py +127 -0
  10. nahiarhdlog-0.1.0/nahiarhdLOG/dashboard/__init__.py +0 -0
  11. nahiarhdlog-0.1.0/nahiarhdLOG/dashboard/router.py +152 -0
  12. nahiarhdlog-0.1.0/nahiarhdLOG/dashboard/static/app.js +342 -0
  13. nahiarhdlog-0.1.0/nahiarhdLOG/dashboard/static/index.html +116 -0
  14. nahiarhdlog-0.1.0/nahiarhdLOG/dashboard/static/lock.html +51 -0
  15. nahiarhdlog-0.1.0/nahiarhdLOG/dashboard/static/styles.css +237 -0
  16. nahiarhdlog-0.1.0/nahiarhdLOG/grouping.py +32 -0
  17. nahiarhdlog-0.1.0/nahiarhdLOG/handler.py +99 -0
  18. nahiarhdlog-0.1.0/nahiarhdLOG/metrics.py +74 -0
  19. nahiarhdlog-0.1.0/nahiarhdLOG/middleware.py +122 -0
  20. nahiarhdlog-0.1.0/nahiarhdLOG/otel.py +92 -0
  21. nahiarhdlog-0.1.0/nahiarhdLOG/py.typed +0 -0
  22. nahiarhdlog-0.1.0/nahiarhdLOG/query.py +86 -0
  23. nahiarhdlog-0.1.0/nahiarhdLOG/storage.py +279 -0
  24. nahiarhdlog-0.1.0/nahiarhdlog.egg-info/PKG-INFO +198 -0
  25. nahiarhdlog-0.1.0/nahiarhdlog.egg-info/SOURCES.txt +37 -0
  26. nahiarhdlog-0.1.0/nahiarhdlog.egg-info/dependency_links.txt +1 -0
  27. nahiarhdlog-0.1.0/nahiarhdlog.egg-info/requires.txt +12 -0
  28. nahiarhdlog-0.1.0/nahiarhdlog.egg-info/top_level.txt +1 -0
  29. nahiarhdlog-0.1.0/pyproject.toml +63 -0
  30. nahiarhdlog-0.1.0/setup.cfg +4 -0
  31. nahiarhdlog-0.1.0/tests/test_adapter_boundary.py +45 -0
  32. nahiarhdlog-0.1.0/tests/test_alerter.py +220 -0
  33. nahiarhdlog-0.1.0/tests/test_dashboard.py +176 -0
  34. nahiarhdlog-0.1.0/tests/test_grouping.py +31 -0
  35. nahiarhdlog-0.1.0/tests/test_handler.py +97 -0
  36. nahiarhdlog-0.1.0/tests/test_metrics.py +89 -0
  37. nahiarhdlog-0.1.0/tests/test_middleware.py +106 -0
  38. nahiarhdlog-0.1.0/tests/test_otel.py +54 -0
  39. 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,3 @@
1
+ include LICENSE README.md
2
+ global-exclude *.db *.db-shm *.db-wal
3
+ global-exclude __pycache__ *.py[cod]
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/nahiarhdlog.svg)](https://pypi.org/project/nahiarhdlog/)
42
+ [![Python Version](https://img.shields.io/pypi/pyversions/nahiarhdlog.svg)](https://pypi.org/project/nahiarhdlog/)
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/nahiarhdlog.svg)](https://pypi.org/project/nahiarhdlog/)
4
+ [![Python Version](https://img.shields.io/pypi/pyversions/nahiarhdlog.svg)](https://pypi.org/project/nahiarhdlog/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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