PyObservability 0.1.0__py3-none-any.whl → 1.0.1__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/enums.py +5 -0
- pyobservability/config/settings.py +43 -2
- pyobservability/main.py +22 -34
- pyobservability/monitor.py +136 -81
- pyobservability/static/app.js +322 -247
- pyobservability/static/styles.css +60 -7
- pyobservability/templates/index.html +16 -10
- pyobservability/transport.py +71 -0
- pyobservability/version.py +1 -1
- {pyobservability-0.1.0.dist-info → pyobservability-1.0.1.dist-info}/METADATA +2 -2
- pyobservability-1.0.1.dist-info/RECORD +16 -0
- pyobservability-0.1.0.dist-info/RECORD +0 -15
- {pyobservability-0.1.0.dist-info → pyobservability-1.0.1.dist-info}/WHEEL +0 -0
- {pyobservability-0.1.0.dist-info → pyobservability-1.0.1.dist-info}/entry_points.txt +0 -0
- {pyobservability-0.1.0.dist-info → pyobservability-1.0.1.dist-info}/licenses/LICENSE +0 -0
- {pyobservability-0.1.0.dist-info → pyobservability-1.0.1.dist-info}/top_level.txt +0 -0
pyobservability/config/enums.py
CHANGED
|
@@ -2,13 +2,49 @@ 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
|
+
from pyobservability.config import enums
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detailed_log_config(filename: str | None = None, debug: bool = False) -> Dict[str, Any]:
|
|
16
|
+
if filename:
|
|
17
|
+
log_handler = {
|
|
18
|
+
"class": "logging.FileHandler",
|
|
19
|
+
"formatter": "default",
|
|
20
|
+
"filename": filename,
|
|
21
|
+
"mode": "a",
|
|
22
|
+
}
|
|
23
|
+
else:
|
|
24
|
+
log_handler = {
|
|
25
|
+
"class": "logging.StreamHandler",
|
|
26
|
+
"formatter": "default",
|
|
27
|
+
"stream": "ext://sys.stdout",
|
|
28
|
+
}
|
|
29
|
+
level = "DEBUG" if debug else "INFO"
|
|
30
|
+
return {
|
|
31
|
+
"version": 1,
|
|
32
|
+
"disable_existing_loggers": False,
|
|
33
|
+
"formatters": {
|
|
34
|
+
"default": {
|
|
35
|
+
"format": "%(asctime)s - %(levelname)s - [%(module)s:%(lineno)d] - %(funcName)s - %(message)s",
|
|
36
|
+
"datefmt": "%b-%d-%Y %I:%M:%S %p",
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"handlers": {"default": log_handler},
|
|
40
|
+
"loggers": {
|
|
41
|
+
"uvicorn": {"handlers": ["default"], "level": level},
|
|
42
|
+
"uvicorn.error": {"handlers": ["default"], "level": level, "propagate": False},
|
|
43
|
+
"uvicorn.access": {"handlers": ["default"], "level": level, "propagate": False},
|
|
44
|
+
},
|
|
45
|
+
"root": {"handlers": ["default"], "level": level},
|
|
46
|
+
}
|
|
47
|
+
|
|
12
48
|
|
|
13
49
|
class PydanticEnvConfig(BaseSettings):
|
|
14
50
|
"""Pydantic BaseSettings with custom order for loading environment variables.
|
|
@@ -63,6 +99,10 @@ class EnvConfig(PydanticEnvConfig):
|
|
|
63
99
|
targets: List[MonitorTarget] = Field(..., validation_alias=alias_choices("TARGETS"))
|
|
64
100
|
interval: PositiveInt = Field(3, validation_alias=alias_choices("INTERVAL"))
|
|
65
101
|
|
|
102
|
+
log: enums.Log | None = None
|
|
103
|
+
debug: bool = False
|
|
104
|
+
log_config: Dict[str, Any] | FilePath | None = None
|
|
105
|
+
|
|
66
106
|
username: str | None = Field(None, validation_alias=alias_choices("USERNAME"))
|
|
67
107
|
password: str | None = Field(None, validation_alias=alias_choices("PASSWORD"))
|
|
68
108
|
|
|
@@ -124,3 +164,4 @@ def env_loader(**kwargs) -> EnvConfig:
|
|
|
124
164
|
|
|
125
165
|
|
|
126
166
|
env: EnvConfig
|
|
167
|
+
targets_by_url: Dict[str, Dict[str, str]]
|
pyobservability/main.py
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import os
|
|
2
3
|
import pathlib
|
|
3
4
|
import warnings
|
|
5
|
+
from datetime import datetime
|
|
4
6
|
|
|
5
|
-
import time
|
|
6
7
|
import uiauth
|
|
7
8
|
import uvicorn
|
|
8
|
-
from fastapi import FastAPI, Request
|
|
9
|
+
from fastapi import FastAPI, Request
|
|
9
10
|
from fastapi.routing import APIRoute, APIWebSocketRoute
|
|
10
11
|
from fastapi.staticfiles import StaticFiles
|
|
11
12
|
from fastapi.templating import Jinja2Templates
|
|
12
13
|
|
|
13
14
|
from pyobservability.config import enums, settings
|
|
14
|
-
from pyobservability.
|
|
15
|
+
from pyobservability.transport import websocket_endpoint
|
|
16
|
+
from pyobservability.version import __version__
|
|
15
17
|
|
|
16
18
|
LOGGER = logging.getLogger("uvicorn.default")
|
|
17
19
|
|
|
@@ -33,37 +35,9 @@ async def index(request: Request):
|
|
|
33
35
|
Args:
|
|
34
36
|
request: FastAPI request object.
|
|
35
37
|
"""
|
|
36
|
-
return templates.TemplateResponse(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
async def websocket_endpoint(websocket: WebSocket):
|
|
40
|
-
"""Websocket endpoint to render the metrics.
|
|
41
|
-
|
|
42
|
-
Args:
|
|
43
|
-
websocket: FastAPI websocket object.
|
|
44
|
-
"""
|
|
45
|
-
monitor = Monitor(targets=settings.env.targets, poll_interval=settings.env.interval)
|
|
46
|
-
await websocket.accept()
|
|
47
|
-
await monitor.start()
|
|
48
|
-
q = monitor.subscribe()
|
|
49
|
-
try:
|
|
50
|
-
while True:
|
|
51
|
-
start = time.time()
|
|
52
|
-
payload = await q.get()
|
|
53
|
-
end = time.time()
|
|
54
|
-
nodes = [d['name'] for d in payload["data"]]
|
|
55
|
-
LOGGER.debug("Payload generated in %s - %d %s", end - start, len(nodes), nodes)
|
|
56
|
-
# send as JSON text
|
|
57
|
-
await websocket.send_json(payload)
|
|
58
|
-
except WebSocketDisconnect:
|
|
59
|
-
monitor.unsubscribe(q)
|
|
60
|
-
except Exception:
|
|
61
|
-
monitor.unsubscribe(q)
|
|
62
|
-
try:
|
|
63
|
-
await websocket.close()
|
|
64
|
-
except Exception as err:
|
|
65
|
-
LOGGER.warning(err)
|
|
66
|
-
await monitor.stop()
|
|
38
|
+
return templates.TemplateResponse(
|
|
39
|
+
"index.html", {"request": request, "targets": settings.env.targets, "version": __version__}
|
|
40
|
+
)
|
|
67
41
|
|
|
68
42
|
|
|
69
43
|
def include_routes():
|
|
@@ -72,6 +46,7 @@ def include_routes():
|
|
|
72
46
|
app=PyObservability,
|
|
73
47
|
username=settings.env.username,
|
|
74
48
|
password=settings.env.password,
|
|
49
|
+
custom_logger=LOGGER,
|
|
75
50
|
params=[
|
|
76
51
|
uiauth.Parameters(
|
|
77
52
|
path=enums.APIEndpoints.root,
|
|
@@ -106,10 +81,23 @@ def include_routes():
|
|
|
106
81
|
def start(**kwargs):
|
|
107
82
|
settings.env = settings.env_loader(**kwargs)
|
|
108
83
|
settings.env.targets = [{k: str(v) for k, v in target.model_dump().items()} for target in settings.env.targets]
|
|
84
|
+
settings.targets_by_url = {t["base_url"]: t for t in settings.env.targets}
|
|
109
85
|
include_routes()
|
|
110
86
|
uvicorn_args = dict(
|
|
111
87
|
host=settings.env.host,
|
|
112
88
|
port=settings.env.port,
|
|
113
89
|
app=PyObservability,
|
|
114
90
|
)
|
|
91
|
+
if log := settings.env.log:
|
|
92
|
+
if log == enums.Log.stdout:
|
|
93
|
+
uvicorn_args["log_config"] = settings.detailed_log_config(debug=settings.env.debug)
|
|
94
|
+
else:
|
|
95
|
+
log_file = datetime.now().strftime(os.path.join("logs", "pyobservability_%d-%m-%Y.log"))
|
|
96
|
+
os.makedirs("logs", exist_ok=True)
|
|
97
|
+
uvicorn_args["log_config"] = settings.detailed_log_config(filename=log_file, debug=settings.env.debug)
|
|
98
|
+
# log_config will take precedence if both log and log_config are set
|
|
99
|
+
if settings.env.log_config:
|
|
100
|
+
uvicorn_args["log_config"] = (
|
|
101
|
+
settings.env.log_config if isinstance(settings.env.log_config, dict) else str(settings.env.log_config)
|
|
102
|
+
)
|
|
115
103
|
uvicorn.run(**uvicorn_args)
|
pyobservability/monitor.py
CHANGED
|
@@ -2,120 +2,175 @@ import asyncio
|
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
4
|
from asyncio import CancelledError
|
|
5
|
-
from
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
from typing import Any, Dict, List
|
|
6
7
|
|
|
7
8
|
import aiohttp
|
|
8
9
|
|
|
10
|
+
from pyobservability.config import settings
|
|
11
|
+
|
|
9
12
|
LOGGER = logging.getLogger("uvicorn.default")
|
|
10
13
|
OBS_PATH = "/observability"
|
|
11
14
|
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
############################################################################
|
|
23
|
-
# LIFECYCLE
|
|
24
|
-
############################################################################
|
|
25
|
-
async def start(self):
|
|
26
|
-
self._tasks = []
|
|
27
|
-
|
|
28
|
-
for target in self.targets:
|
|
29
|
-
base = target["base_url"]
|
|
30
|
-
name = target.get("name")
|
|
31
|
-
apikey = target.get("apikey")
|
|
32
|
-
|
|
33
|
-
session = aiohttp.ClientSession()
|
|
34
|
-
self.sessions[base] = session
|
|
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
|
|
35
24
|
|
|
36
|
-
task = asyncio.create_task(self._stream_target(name, base, session, apikey))
|
|
37
|
-
self._tasks.append(task)
|
|
38
25
|
|
|
39
|
-
|
|
40
|
-
|
|
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}
|
|
41
32
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
await task
|
|
46
|
-
except CancelledError:
|
|
47
|
-
pass
|
|
33
|
+
self.session: aiohttp.ClientSession | None = None
|
|
34
|
+
self._task: asyncio.Task | None = None
|
|
35
|
+
self._stop = asyncio.Event()
|
|
48
36
|
|
|
49
|
-
|
|
50
|
-
await session.close()
|
|
37
|
+
self._ws_subscribers = [] # list of asyncio.Queue
|
|
51
38
|
|
|
52
|
-
|
|
39
|
+
# ------------------------------
|
|
53
40
|
# SUBSCRIBE / UNSUBSCRIBE
|
|
54
|
-
|
|
41
|
+
# ------------------------------
|
|
55
42
|
def subscribe(self) -> asyncio.Queue:
|
|
56
43
|
q = asyncio.Queue(maxsize=10)
|
|
57
44
|
self._ws_subscribers.append(q)
|
|
58
45
|
return q
|
|
59
46
|
|
|
60
47
|
def unsubscribe(self, q: asyncio.Queue):
|
|
61
|
-
|
|
48
|
+
if q in self._ws_subscribers:
|
|
62
49
|
self._ws_subscribers.remove(q)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
50
|
+
|
|
51
|
+
# ------------------------------
|
|
52
|
+
# START / STOP
|
|
53
|
+
# ------------------------------
|
|
54
|
+
async def start(self):
|
|
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())
|
|
62
|
+
|
|
63
|
+
async def stop(self):
|
|
64
|
+
self._stop.set()
|
|
65
|
+
if self._task:
|
|
66
|
+
self._task.cancel()
|
|
67
|
+
try:
|
|
68
|
+
await self._task
|
|
69
|
+
except CancelledError:
|
|
70
|
+
pass
|
|
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)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
async for raw in resp.content:
|
|
107
|
+
line = raw.decode().strip()
|
|
108
|
+
if not line:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
parsed = json.loads(line)
|
|
84
113
|
try:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
|
|
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 = {}
|
|
98
127
|
while not self._stop.is_set():
|
|
99
128
|
try:
|
|
100
|
-
async for payload in self.
|
|
129
|
+
async for payload in self._fetch_stream():
|
|
101
130
|
result = {
|
|
102
131
|
"type": "metrics",
|
|
103
132
|
"ts": asyncio.get_event_loop().time(),
|
|
104
|
-
"data": [
|
|
133
|
+
"data": [
|
|
134
|
+
{
|
|
135
|
+
"name": self.base_url,
|
|
136
|
+
"base_url": self.base_url,
|
|
137
|
+
"metrics": payload,
|
|
138
|
+
}
|
|
139
|
+
],
|
|
105
140
|
}
|
|
106
141
|
|
|
107
|
-
# The queue gets full when subscribers aren’t reading fast enough
|
|
108
|
-
# [OR]
|
|
109
|
-
# The monitor produces new metrics faster than the queue’s fixed maxsize of 10 can be consumed
|
|
110
142
|
for q in list(self._ws_subscribers):
|
|
111
143
|
try:
|
|
112
144
|
q.put_nowait(result)
|
|
113
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):
|
|
114
165
|
try:
|
|
166
|
+
q.put_nowait(error_msg)
|
|
167
|
+
except asyncio.QueueFull as warn:
|
|
168
|
+
LOGGER.warning(warn)
|
|
115
169
|
_ = q.get_nowait()
|
|
116
170
|
q.put_nowait(result)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
171
|
+
await self.stop()
|
|
172
|
+
return
|
|
173
|
+
else:
|
|
174
|
+
errors[self.base_url] = 1
|
|
175
|
+
|
|
121
176
|
await asyncio.sleep(1)
|