PyObservability 0.0.3__py3-none-any.whl → 1.0.0__py3-none-any.whl
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.
- pyobservability/config/settings.py +31 -2
- pyobservability/main.py +12 -32
- pyobservability/monitor.py +146 -168
- pyobservability/static/app.js +366 -185
- pyobservability/static/styles.css +62 -6
- pyobservability/templates/index.html +48 -11
- pyobservability/transport.py +71 -0
- pyobservability/version.py +1 -1
- {pyobservability-0.0.3.dist-info → pyobservability-1.0.0.dist-info}/METADATA +1 -1
- pyobservability-1.0.0.dist-info/RECORD +16 -0
- pyobservability-0.0.3.dist-info/RECORD +0 -15
- {pyobservability-0.0.3.dist-info → pyobservability-1.0.0.dist-info}/WHEEL +0 -0
- {pyobservability-0.0.3.dist-info → pyobservability-1.0.0.dist-info}/entry_points.txt +0 -0
- {pyobservability-0.0.3.dist-info → pyobservability-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {pyobservability-0.0.3.dist-info → pyobservability-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -2,14 +2,40 @@ import json
|
|
|
2
2
|
import os
|
|
3
3
|
import pathlib
|
|
4
4
|
import socket
|
|
5
|
-
from typing import List
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
6
|
|
|
7
7
|
import yaml
|
|
8
|
-
from pydantic import BaseModel, Field, HttpUrl, PositiveInt
|
|
8
|
+
from pydantic import BaseModel, Field, FilePath, HttpUrl, PositiveInt
|
|
9
9
|
from pydantic.aliases import AliasChoices
|
|
10
10
|
from pydantic_settings import BaseSettings
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
def detailed_log_config() -> Dict[str, Any]:
|
|
14
|
+
return {
|
|
15
|
+
"version": 1,
|
|
16
|
+
"disable_existing_loggers": False,
|
|
17
|
+
"formatters": {
|
|
18
|
+
"default": {
|
|
19
|
+
"format": "%(asctime)s - %(levelname)s - [%(module)s:%(lineno)d] - %(funcName)s - %(message)s",
|
|
20
|
+
"datefmt": "%b-%d-%Y %I:%M:%S %p",
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"handlers": {
|
|
24
|
+
"default": {
|
|
25
|
+
"class": "logging.StreamHandler",
|
|
26
|
+
"formatter": "default",
|
|
27
|
+
"stream": "ext://sys.stdout",
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"loggers": {
|
|
31
|
+
"uvicorn": {"handlers": ["default"], "level": "INFO"},
|
|
32
|
+
"uvicorn.error": {"handlers": ["default"], "level": "INFO", "propagate": False},
|
|
33
|
+
"uvicorn.access": {"handlers": ["default"], "level": "INFO", "propagate": False},
|
|
34
|
+
},
|
|
35
|
+
"root": {"handlers": ["default"], "level": "INFO"},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
13
39
|
class PydanticEnvConfig(BaseSettings):
|
|
14
40
|
"""Pydantic BaseSettings with custom order for loading environment variables.
|
|
15
41
|
|
|
@@ -63,6 +89,8 @@ class EnvConfig(PydanticEnvConfig):
|
|
|
63
89
|
targets: List[MonitorTarget] = Field(..., validation_alias=alias_choices("TARGETS"))
|
|
64
90
|
interval: PositiveInt = Field(3, validation_alias=alias_choices("INTERVAL"))
|
|
65
91
|
|
|
92
|
+
log_config: Dict[str, Any] | FilePath | None = None
|
|
93
|
+
|
|
66
94
|
username: str | None = Field(None, validation_alias=alias_choices("USERNAME"))
|
|
67
95
|
password: str | None = Field(None, validation_alias=alias_choices("PASSWORD"))
|
|
68
96
|
|
|
@@ -124,3 +152,4 @@ def env_loader(**kwargs) -> EnvConfig:
|
|
|
124
152
|
|
|
125
153
|
|
|
126
154
|
env: EnvConfig
|
|
155
|
+
targets_by_url: Dict[str, Dict[str, str]]
|
pyobservability/main.py
CHANGED
|
@@ -4,13 +4,14 @@ import warnings
|
|
|
4
4
|
|
|
5
5
|
import uiauth
|
|
6
6
|
import uvicorn
|
|
7
|
-
from fastapi import FastAPI, Request
|
|
7
|
+
from fastapi import FastAPI, Request
|
|
8
8
|
from fastapi.routing import APIRoute, APIWebSocketRoute
|
|
9
9
|
from fastapi.staticfiles import StaticFiles
|
|
10
10
|
from fastapi.templating import Jinja2Templates
|
|
11
11
|
|
|
12
12
|
from pyobservability.config import enums, settings
|
|
13
|
-
from pyobservability.
|
|
13
|
+
from pyobservability.transport import websocket_endpoint
|
|
14
|
+
from pyobservability.version import __version__
|
|
14
15
|
|
|
15
16
|
LOGGER = logging.getLogger("uvicorn.default")
|
|
16
17
|
|
|
@@ -32,33 +33,9 @@ async def index(request: Request):
|
|
|
32
33
|
Args:
|
|
33
34
|
request: FastAPI request object.
|
|
34
35
|
"""
|
|
35
|
-
return templates.TemplateResponse(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
async def websocket_endpoint(websocket: WebSocket):
|
|
39
|
-
"""Websocket endpoint to render the metrics.
|
|
40
|
-
|
|
41
|
-
Args:
|
|
42
|
-
websocket: FastAPI websocket object.
|
|
43
|
-
"""
|
|
44
|
-
monitor = Monitor(targets=settings.env.targets, poll_interval=settings.env.interval)
|
|
45
|
-
await websocket.accept()
|
|
46
|
-
await monitor.start()
|
|
47
|
-
q = monitor.subscribe()
|
|
48
|
-
try:
|
|
49
|
-
while True:
|
|
50
|
-
payload = await q.get()
|
|
51
|
-
# send as JSON text
|
|
52
|
-
await websocket.send_json(payload)
|
|
53
|
-
except WebSocketDisconnect:
|
|
54
|
-
monitor.unsubscribe(q)
|
|
55
|
-
except Exception:
|
|
56
|
-
monitor.unsubscribe(q)
|
|
57
|
-
try:
|
|
58
|
-
await websocket.close()
|
|
59
|
-
except Exception as err:
|
|
60
|
-
LOGGER.warning(err)
|
|
61
|
-
await monitor.stop()
|
|
36
|
+
return templates.TemplateResponse(
|
|
37
|
+
"index.html", {"request": request, "targets": settings.env.targets, "version": __version__}
|
|
38
|
+
)
|
|
62
39
|
|
|
63
40
|
|
|
64
41
|
def include_routes():
|
|
@@ -100,13 +77,16 @@ def include_routes():
|
|
|
100
77
|
|
|
101
78
|
def start(**kwargs):
|
|
102
79
|
settings.env = settings.env_loader(**kwargs)
|
|
103
|
-
settings.env.targets = [
|
|
104
|
-
|
|
105
|
-
]
|
|
80
|
+
settings.env.targets = [{k: str(v) for k, v in target.model_dump().items()} for target in settings.env.targets]
|
|
81
|
+
settings.targets_by_url = {t["base_url"]: t for t in settings.env.targets}
|
|
106
82
|
include_routes()
|
|
107
83
|
uvicorn_args = dict(
|
|
108
84
|
host=settings.env.host,
|
|
109
85
|
port=settings.env.port,
|
|
110
86
|
app=PyObservability,
|
|
111
87
|
)
|
|
88
|
+
if settings.env.log_config:
|
|
89
|
+
uvicorn_args["log_config"] = (
|
|
90
|
+
settings.env.log_config if isinstance(settings.env.log_config, dict) else str(settings.env.log_config)
|
|
91
|
+
)
|
|
112
92
|
uvicorn.run(**uvicorn_args)
|
pyobservability/monitor.py
CHANGED
|
@@ -1,85 +1,64 @@
|
|
|
1
|
-
# app/monitor.py
|
|
2
|
-
|
|
3
1
|
import asyncio
|
|
2
|
+
import json
|
|
4
3
|
import logging
|
|
5
4
|
from asyncio import CancelledError
|
|
5
|
+
from collections.abc import Generator
|
|
6
6
|
from typing import Any, Dict, List
|
|
7
|
-
from urllib.parse import urlparse
|
|
8
7
|
|
|
9
8
|
import aiohttp
|
|
10
9
|
|
|
10
|
+
from pyobservability.config import settings
|
|
11
|
+
|
|
11
12
|
LOGGER = logging.getLogger("uvicorn.default")
|
|
13
|
+
OBS_PATH = "/observability"
|
|
12
14
|
|
|
13
|
-
###############################################################################
|
|
14
|
-
# ENDPOINT DEFINITIONS (PyNinja Correct)
|
|
15
|
-
###############################################################################
|
|
16
|
-
|
|
17
|
-
ENDPOINTS = {
|
|
18
|
-
"ip": {
|
|
19
|
-
"path": "/get-ip",
|
|
20
|
-
"params": {"public": "false"},
|
|
21
|
-
},
|
|
22
|
-
"cpu": {
|
|
23
|
-
"path": "/get-cpu",
|
|
24
|
-
"params": {"interval": 2, "per_cpu": "true"},
|
|
25
|
-
},
|
|
26
|
-
"cpu_load": {
|
|
27
|
-
"path": "/get-cpu-load",
|
|
28
|
-
"params": {},
|
|
29
|
-
},
|
|
30
|
-
"gpu": {
|
|
31
|
-
"path": "/get-processor",
|
|
32
|
-
"params": {},
|
|
33
|
-
},
|
|
34
|
-
"memory": {
|
|
35
|
-
"path": "/get-memory",
|
|
36
|
-
"params": {},
|
|
37
|
-
},
|
|
38
|
-
"disk": {
|
|
39
|
-
"path": "/get-disk-utilization",
|
|
40
|
-
"params": {"path": "/"},
|
|
41
|
-
},
|
|
42
|
-
"disks": {
|
|
43
|
-
"path": "/get-all-disks",
|
|
44
|
-
"params": {},
|
|
45
|
-
},
|
|
46
|
-
"services": {
|
|
47
|
-
"path": "/get-all-services",
|
|
48
|
-
"params": {},
|
|
49
|
-
},
|
|
50
|
-
"docker_stats": {
|
|
51
|
-
"path": "/get-docker-stats",
|
|
52
|
-
"params": {},
|
|
53
|
-
},
|
|
54
|
-
"certificates": {
|
|
55
|
-
"path": "/get-certificates",
|
|
56
|
-
"params": {},
|
|
57
|
-
},
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
###############################################################################
|
|
62
|
-
# MONITOR CLASS
|
|
63
|
-
###############################################################################
|
|
64
15
|
|
|
16
|
+
def refine_service(service_list: List[Dict[str, Any]]) -> Generator[Dict[str, Dict[str, str]]]:
|
|
17
|
+
for service in service_list:
|
|
18
|
+
service["memory"] = dict(rss=service.get("memory", {}).get("rss"), vms=service.get("memory", {}).get("vms"))
|
|
19
|
+
service["cpu"] = dict(
|
|
20
|
+
user=round(service.get("cpu", {}).get("user", 0), 2),
|
|
21
|
+
system=round(service.get("cpu", {}).get("system", 0), 2),
|
|
22
|
+
)
|
|
23
|
+
yield service
|
|
65
24
|
|
|
66
|
-
class Monitor:
|
|
67
25
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
self.
|
|
71
|
-
self.
|
|
72
|
-
self.
|
|
73
|
-
self.
|
|
26
|
+
class Monitor:
|
|
27
|
+
def __init__(self, target: Dict[str, str]):
|
|
28
|
+
self.name = target["name"]
|
|
29
|
+
self.base_url = target["base_url"]
|
|
30
|
+
self.apikey = target["apikey"]
|
|
31
|
+
self.flags = {"all_services": False}
|
|
32
|
+
|
|
33
|
+
self.session: aiohttp.ClientSession | None = None
|
|
34
|
+
self._task: asyncio.Task | None = None
|
|
74
35
|
self._stop = asyncio.Event()
|
|
75
36
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
37
|
+
self._ws_subscribers = [] # list of asyncio.Queue
|
|
38
|
+
|
|
39
|
+
# ------------------------------
|
|
40
|
+
# SUBSCRIBE / UNSUBSCRIBE
|
|
41
|
+
# ------------------------------
|
|
42
|
+
def subscribe(self) -> asyncio.Queue:
|
|
43
|
+
q = asyncio.Queue(maxsize=10)
|
|
44
|
+
self._ws_subscribers.append(q)
|
|
45
|
+
return q
|
|
46
|
+
|
|
47
|
+
def unsubscribe(self, q: asyncio.Queue):
|
|
48
|
+
if q in self._ws_subscribers:
|
|
49
|
+
self._ws_subscribers.remove(q)
|
|
50
|
+
|
|
51
|
+
# ------------------------------
|
|
52
|
+
# START / STOP
|
|
53
|
+
# ------------------------------
|
|
79
54
|
async def start(self):
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
55
|
+
if self._task:
|
|
56
|
+
return # already running
|
|
57
|
+
|
|
58
|
+
self._stop.clear()
|
|
59
|
+
self.session = aiohttp.ClientSession()
|
|
60
|
+
|
|
61
|
+
self._task = asyncio.create_task(self._stream_target())
|
|
83
62
|
|
|
84
63
|
async def stop(self):
|
|
85
64
|
self._stop.set()
|
|
@@ -89,110 +68,109 @@ class Monitor:
|
|
|
89
68
|
await self._task
|
|
90
69
|
except CancelledError:
|
|
91
70
|
pass
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
# FETCH
|
|
112
|
-
|
|
113
|
-
async def
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
LOGGER.
|
|
71
|
+
self._task = None
|
|
72
|
+
|
|
73
|
+
if self.session:
|
|
74
|
+
await self.session.close()
|
|
75
|
+
self.session = None
|
|
76
|
+
|
|
77
|
+
async def update_flags(self, **kwargs):
|
|
78
|
+
for k, v in kwargs.items():
|
|
79
|
+
if k in self.flags:
|
|
80
|
+
self.flags[k] = v
|
|
81
|
+
|
|
82
|
+
# restart stream with new params
|
|
83
|
+
await self._restart_stream()
|
|
84
|
+
|
|
85
|
+
async def _restart_stream(self):
|
|
86
|
+
await self.stop()
|
|
87
|
+
await self.start()
|
|
88
|
+
|
|
89
|
+
# ------------------------------
|
|
90
|
+
# FETCH STREAM
|
|
91
|
+
# ------------------------------
|
|
92
|
+
async def _fetch_stream(self):
|
|
93
|
+
query = f"?interval={settings.env.interval}"
|
|
94
|
+
if self.flags["all_services"]:
|
|
95
|
+
query += "&all_services=true"
|
|
96
|
+
url = self.base_url.rstrip("/") + OBS_PATH + query
|
|
97
|
+
headers = {"Accept": "application/json", "Authorization": f"Bearer {self.apikey}"}
|
|
98
|
+
|
|
99
|
+
async with self.session.get(
|
|
100
|
+
url, headers=headers, timeout=aiohttp.ClientTimeout(total=None, connect=3, sock_read=None, sock_connect=3)
|
|
101
|
+
) as resp:
|
|
102
|
+
if resp.status != 200:
|
|
103
|
+
LOGGER.error("Bad response [%d] from %s", resp.status, url)
|
|
125
104
|
return
|
|
126
|
-
except Exception as err:
|
|
127
|
-
LOGGER.debug(err)
|
|
128
|
-
return
|
|
129
|
-
|
|
130
|
-
############################################################################
|
|
131
|
-
# PER-TARGET POLLING
|
|
132
|
-
############################################################################
|
|
133
|
-
async def _poll_target(self, target: Dict[str, Any]) -> Dict[str, Any]:
|
|
134
|
-
base = target["base_url"]
|
|
135
|
-
apikey = target["apikey"]
|
|
136
|
-
session = self.sessions[base]
|
|
137
|
-
headers = {"Accept": "application/json", "Authorization": f"Bearer {apikey}"}
|
|
138
|
-
|
|
139
|
-
result = {"name": target["name"], "base_url": base, "metrics": {}}
|
|
140
|
-
|
|
141
|
-
# Fire ALL requests concurrently
|
|
142
|
-
tasks = {}
|
|
143
|
-
|
|
144
|
-
for key, cfg in ENDPOINTS.items():
|
|
145
|
-
tasks[key] = asyncio.create_task(
|
|
146
|
-
self._fetch(session, base, cfg["path"], headers=headers, params=cfg["params"])
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
# Wait for all endpoints
|
|
150
|
-
raw_results = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
|
151
|
-
|
|
152
|
-
for (key, _), resp in zip(tasks.items(), raw_results):
|
|
153
|
-
if isinstance(resp, Exception):
|
|
154
|
-
result["metrics"][key] = None
|
|
155
|
-
continue
|
|
156
|
-
if isinstance(resp, dict):
|
|
157
|
-
result["metrics"][key] = resp.get("detail", resp)
|
|
158
|
-
else:
|
|
159
|
-
# raw string / number / list / etc
|
|
160
|
-
result["metrics"][key] = resp
|
|
161
|
-
|
|
162
|
-
return result
|
|
163
|
-
|
|
164
|
-
############################################################################
|
|
165
|
-
# POLL ALL HOSTS
|
|
166
|
-
############################################################################
|
|
167
|
-
async def _poll_all(self) -> List[Dict[str, Any]]:
|
|
168
|
-
tasks = [self._poll_target(target) for target in self.targets]
|
|
169
|
-
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
170
|
-
out = []
|
|
171
|
-
for r in results:
|
|
172
|
-
if isinstance(r, Exception):
|
|
173
|
-
LOGGER.error("%s", r)
|
|
174
|
-
out.append({"error": str(r)})
|
|
175
|
-
else:
|
|
176
|
-
out.append(r)
|
|
177
|
-
return out
|
|
178
|
-
|
|
179
|
-
############################################################################
|
|
180
|
-
# MAIN LOOP
|
|
181
|
-
############################################################################
|
|
182
|
-
async def _run_loop(self):
|
|
183
|
-
while not self._stop.is_set():
|
|
184
|
-
metrics = await self._poll_all()
|
|
185
105
|
|
|
186
|
-
|
|
106
|
+
async for raw in resp.content:
|
|
107
|
+
line = raw.decode().strip()
|
|
108
|
+
if not line:
|
|
109
|
+
continue
|
|
187
110
|
|
|
188
|
-
for q in list(self._ws_subscribers):
|
|
189
111
|
try:
|
|
190
|
-
|
|
191
|
-
except asyncio.QueueFull:
|
|
112
|
+
parsed = json.loads(line)
|
|
192
113
|
try:
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
except Exception:
|
|
196
|
-
|
|
114
|
+
if service_stats := parsed.get("service_stats"):
|
|
115
|
+
parsed["service_stats"] = list(refine_service(service_stats))
|
|
116
|
+
except Exception as error:
|
|
117
|
+
LOGGER.error("Received [%s: %s] when parsing services for %s", type(error), error, self.name)
|
|
118
|
+
yield parsed
|
|
119
|
+
except json.JSONDecodeError:
|
|
120
|
+
LOGGER.debug("Bad JSON from %s: %s", self.base_url, line)
|
|
121
|
+
|
|
122
|
+
# ------------------------------
|
|
123
|
+
# STREAM LOOP
|
|
124
|
+
# ------------------------------
|
|
125
|
+
async def _stream_target(self):
|
|
126
|
+
errors = {}
|
|
127
|
+
while not self._stop.is_set():
|
|
128
|
+
try:
|
|
129
|
+
async for payload in self._fetch_stream():
|
|
130
|
+
result = {
|
|
131
|
+
"type": "metrics",
|
|
132
|
+
"ts": asyncio.get_event_loop().time(),
|
|
133
|
+
"data": [
|
|
134
|
+
{
|
|
135
|
+
"name": self.base_url,
|
|
136
|
+
"base_url": self.base_url,
|
|
137
|
+
"metrics": payload,
|
|
138
|
+
}
|
|
139
|
+
],
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for q in list(self._ws_subscribers):
|
|
143
|
+
try:
|
|
144
|
+
q.put_nowait(result)
|
|
145
|
+
except asyncio.QueueFull:
|
|
146
|
+
_ = q.get_nowait()
|
|
147
|
+
q.put_nowait(result)
|
|
148
|
+
except Exception as err:
|
|
149
|
+
LOGGER.debug("Stream error for %s: %s", self.base_url, err)
|
|
150
|
+
if errors.get(self.base_url):
|
|
151
|
+
if errors[self.base_url] < 10:
|
|
152
|
+
errors[self.base_url] += 1
|
|
153
|
+
else:
|
|
154
|
+
LOGGER.error(err.with_traceback())
|
|
155
|
+
LOGGER.error("%s exceeded error threshold.", self.base_url)
|
|
156
|
+
|
|
157
|
+
# notify subscribers before stopping
|
|
158
|
+
error_msg = {
|
|
159
|
+
"type": "error",
|
|
160
|
+
"base_url": self.base_url,
|
|
161
|
+
"message": f"{self.name!r} is unreachable.",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for q in list(self._ws_subscribers):
|
|
165
|
+
try:
|
|
166
|
+
q.put_nowait(error_msg)
|
|
167
|
+
except asyncio.QueueFull as warn:
|
|
168
|
+
LOGGER.warning(warn)
|
|
169
|
+
_ = q.get_nowait()
|
|
170
|
+
q.put_nowait(result)
|
|
171
|
+
await self.stop()
|
|
172
|
+
return
|
|
173
|
+
else:
|
|
174
|
+
errors[self.base_url] = 1
|
|
197
175
|
|
|
198
|
-
await asyncio.sleep(
|
|
176
|
+
await asyncio.sleep(1)
|