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.
@@ -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, WebSocket, WebSocketDisconnect
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.monitor import Monitor
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("index.html", {"request": request, "targets": settings.env.targets})
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
- {k: str(v) for k, v in target.model_dump().items()} for target in settings.env.targets
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)
@@ -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
- def __init__(self, targets: List[Dict[str, str]], poll_interval: float):
69
- self.targets = targets
70
- self.poll_interval = poll_interval
71
- self.sessions: Dict[str, aiohttp.ClientSession] = {}
72
- self._ws_subscribers: List[asyncio.Queue] = []
73
- self._task = None
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
- # LIFECYCLE
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
- for target in self.targets:
81
- self.sessions[target["base_url"]] = aiohttp.ClientSession()
82
- self._task = asyncio.create_task(self._run_loop())
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
- for sess in self.sessions.values():
94
- await sess.close()
95
-
96
- ############################################################################
97
- # SUBSCRIBE / UNSUBSCRIBE
98
- ############################################################################
99
- def subscribe(self) -> asyncio.Queue:
100
- q = asyncio.Queue(maxsize=10)
101
- self._ws_subscribers.append(q)
102
- return q
103
-
104
- def unsubscribe(self, q: asyncio.Queue):
105
- try:
106
- self._ws_subscribers.remove(q)
107
- except ValueError:
108
- pass
109
-
110
- ############################################################################
111
- # FETCH WRAPPER
112
- ############################################################################
113
- async def _fetch(self, session, base_url, ep, headers: Dict[str, str], params=None):
114
- url = base_url.rstrip("/") + ep
115
- try:
116
- async with session.get(url, headers=headers, params=params, timeout=10) as resp:
117
- if resp.status == 200:
118
- try:
119
- return await resp.json()
120
- except Exception as err:
121
- LOGGER.debug(err)
122
- return
123
- parsed = urlparse(url)
124
- LOGGER.debug("Exception on '%s' - [%d]: %s", parsed.path, resp.status, await resp.text())
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
- payload = {"type": "metrics", "ts": asyncio.get_event_loop().time(), "data": metrics}
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
- q.put_nowait(payload)
191
- except asyncio.QueueFull:
112
+ parsed = json.loads(line)
192
113
  try:
193
- _ = q.get_nowait()
194
- q.put_nowait(payload)
195
- except Exception:
196
- pass
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(self.poll_interval)
176
+ await asyncio.sleep(1)