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.
@@ -4,3 +4,8 @@ from enum import StrEnum
4
4
  class APIEndpoints(StrEnum):
5
5
  root = "/"
6
6
  ws = "/ws"
7
+
8
+
9
+ class Log(StrEnum):
10
+ file = "file"
11
+ stdout = "stdout"
@@ -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, WebSocket, WebSocketDisconnect
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.monitor import Monitor
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("index.html", {"request": request, "targets": settings.env.targets})
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)
@@ -2,120 +2,175 @@ import asyncio
2
2
  import json
3
3
  import logging
4
4
  from asyncio import CancelledError
5
- from typing import Dict, List
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
- class Monitor:
14
- def __init__(self, targets: List[Dict[str, str]], poll_interval: float):
15
- self.targets = targets
16
- self.poll_interval = poll_interval
17
- self.sessions: Dict[str, aiohttp.ClientSession] = {}
18
- self._ws_subscribers: List[asyncio.Queue] = []
19
- self._task: asyncio.Task | None = None
20
- self._stop = asyncio.Event()
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
- async def stop(self):
40
- self._stop.set()
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
- for task in self._tasks:
43
- task.cancel()
44
- try:
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
- for session in self.sessions.values():
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
- try:
48
+ if q in self._ws_subscribers:
62
49
  self._ws_subscribers.remove(q)
63
- except ValueError:
64
- pass
65
-
66
- ############################################################################
67
- # FETCH OBSERVABILITY FOR SINGLE TARGET
68
- ############################################################################
69
- async def _fetch_observability(self, session, base_url, apikey):
70
- url = base_url.rstrip("/") + OBS_PATH + f"?interval={self.poll_interval}"
71
- headers = {"accept": "application/json"}
72
- if apikey:
73
- headers["Authorization"] = f"Bearer {apikey}"
74
-
75
- try:
76
- async with session.get(url, headers=headers, timeout=None) as resp:
77
- if resp.status != 200:
78
- LOGGER.debug("Exception - [%d]: %s", resp.status, await resp.text())
79
- return
80
- async for line in resp.content:
81
- line = line.decode().strip()
82
- if not line:
83
- continue
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
- payload = json.loads(line)
86
- # yield each record instead of returning
87
- yield payload
88
- except json.JSONDecodeError as err:
89
- LOGGER.debug("JSON decode error: %s | line=%s", err, line)
90
- except Exception as err:
91
- LOGGER.debug("Exception: %s", err)
92
- return
93
-
94
- ############################################################################
95
- # STREAM A SPECIFIC TARGET - SEQUENTIAL OVER ALL TARGETS
96
- ############################################################################
97
- async def _stream_target(self, name, base, session, apikey):
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._fetch_observability(session, base, apikey):
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": [{"name": name, "base_url": base, "metrics": payload}],
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
- except Exception as debug:
118
- LOGGER.debug(f"Queue full: {debug}")
119
- except Exception as debug:
120
- LOGGER.debug(f"Stream failed for {base}, retrying: {debug}")
171
+ await self.stop()
172
+ return
173
+ else:
174
+ errors[self.base_url] = 1
175
+
121
176
  await asyncio.sleep(1)