PyObservability 0.1.0__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
@@ -2,16 +2,16 @@ import logging
2
2
  import pathlib
3
3
  import warnings
4
4
 
5
- import time
6
5
  import uiauth
7
6
  import uvicorn
8
- from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
7
+ from fastapi import FastAPI, Request
9
8
  from fastapi.routing import APIRoute, APIWebSocketRoute
10
9
  from fastapi.staticfiles import StaticFiles
11
10
  from fastapi.templating import Jinja2Templates
12
11
 
13
12
  from pyobservability.config import enums, settings
14
- from pyobservability.monitor import Monitor
13
+ from pyobservability.transport import websocket_endpoint
14
+ from pyobservability.version import __version__
15
15
 
16
16
  LOGGER = logging.getLogger("uvicorn.default")
17
17
 
@@ -33,37 +33,9 @@ async def index(request: Request):
33
33
  Args:
34
34
  request: FastAPI request object.
35
35
  """
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()
36
+ return templates.TemplateResponse(
37
+ "index.html", {"request": request, "targets": settings.env.targets, "version": __version__}
38
+ )
67
39
 
68
40
 
69
41
  def include_routes():
@@ -106,10 +78,15 @@ def include_routes():
106
78
  def start(**kwargs):
107
79
  settings.env = settings.env_loader(**kwargs)
108
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}
109
82
  include_routes()
110
83
  uvicorn_args = dict(
111
84
  host=settings.env.host,
112
85
  port=settings.env.port,
113
86
  app=PyObservability,
114
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
+ )
115
92
  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)