monsta 0.1.0__tar.gz
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.
- monsta-0.1.0/PKG-INFO +296 -0
- monsta-0.1.0/README.md +284 -0
- monsta-0.1.0/pyproject.toml +44 -0
- monsta-0.1.0/src/monsta/__init__.py +10 -0
- monsta-0.1.0/src/monsta/aiomon.py +185 -0
- monsta-0.1.0/src/monsta/mon.py +318 -0
- monsta-0.1.0/src/monsta/py.typed +0 -0
monsta-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: monsta
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple monitoring REST API for Python applications
|
|
5
|
+
Author: Stefan Schönberger
|
|
6
|
+
Author-email: Stefan Schönberger <stefan@sniner.dev>
|
|
7
|
+
Requires-Dist: fastapi>=0.133.1
|
|
8
|
+
Requires-Dist: pydantic>=2.12.5
|
|
9
|
+
Requires-Dist: uvicorn>=0.41.0
|
|
10
|
+
Requires-Python: >=3.14
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Monsta - Status Reporting REST API for Python Applications
|
|
14
|
+
|
|
15
|
+
**Monsta** (short for "Monitoring State") is a lightweight library for Python applications that provides a REST API endpoint for exposing application state and metrics. It's designed for seamless integration with FastAPI.
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Simple Integration**: Add monitoring to your application with just a few lines of code
|
|
20
|
+
- **Async Support**: Native async/await support for FastAPI
|
|
21
|
+
- **Thread-Safe**: Built-in thread safety for concurrent access
|
|
22
|
+
- **Flexible State Management**: Support for both direct state values and callback functions
|
|
23
|
+
- **Built-in Metrics**: Automatic uptime tracking (and possibly other internal metrics)
|
|
24
|
+
- **Customizable**: Configure endpoint paths, ports, and update intervals
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install monsta
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
### Basic Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from monsta import StatusReporter
|
|
38
|
+
|
|
39
|
+
# Create status reporter
|
|
40
|
+
mon = StatusReporter()
|
|
41
|
+
|
|
42
|
+
# Set application state
|
|
43
|
+
mon.publish({"status": "running", "version": "1.0.0"})
|
|
44
|
+
|
|
45
|
+
# Start status reporting server (blocking)
|
|
46
|
+
mon.start(blocking=True)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### FastAPI Integration
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from fastapi import FastAPI
|
|
53
|
+
from monsta import StatusReporter
|
|
54
|
+
|
|
55
|
+
app = FastAPI()
|
|
56
|
+
|
|
57
|
+
# Create and integrate status reporter
|
|
58
|
+
mon = StatusReporter(endpoint="/api/v1/monitoring")
|
|
59
|
+
app.include_router(mon.router)
|
|
60
|
+
|
|
61
|
+
# Update state during application lifecycle
|
|
62
|
+
mon.publish({"status": "running", "requests": 0})
|
|
63
|
+
|
|
64
|
+
# Start FastAPI app
|
|
65
|
+
# Status reporting will be available at /api/v1/monitoring
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Async FastAPI Integration
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from contextlib import asynccontextmanager
|
|
72
|
+
from fastapi import FastAPI
|
|
73
|
+
from monsta import AsyncStatusReporter
|
|
74
|
+
|
|
75
|
+
@asynccontextmanager
|
|
76
|
+
async def lifespan(app: FastAPI):
|
|
77
|
+
# Initialize status reporting
|
|
78
|
+
app.state.mon = AsyncStatusReporter(endpoint="/api/v1/state")
|
|
79
|
+
app.include_router(app.state.mon.router)
|
|
80
|
+
|
|
81
|
+
# Start async status reporting
|
|
82
|
+
await app.state.mon.start(state={"status": "starting"})
|
|
83
|
+
|
|
84
|
+
yield
|
|
85
|
+
|
|
86
|
+
# Clean up
|
|
87
|
+
await app.state.mon.stop()
|
|
88
|
+
|
|
89
|
+
app = FastAPI(lifespan=lifespan)
|
|
90
|
+
|
|
91
|
+
@app.get("/")
|
|
92
|
+
async def root():
|
|
93
|
+
# Update status asynchronously
|
|
94
|
+
await app.state.mon.publish({"status": "running", "requests": 1})
|
|
95
|
+
return {"message": "Hello World"}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## API Reference
|
|
99
|
+
|
|
100
|
+
### StatusReporter
|
|
101
|
+
|
|
102
|
+
The main synchronous status reporter class.
|
|
103
|
+
|
|
104
|
+
#### `StatusReporter(endpoint: Optional[str] = None)`
|
|
105
|
+
|
|
106
|
+
- `endpoint`: Custom endpoint path for the status API (default: `/mon/v1/state`)
|
|
107
|
+
|
|
108
|
+
#### `publish(state: StateSource) -> Self`
|
|
109
|
+
|
|
110
|
+
Set the application state.
|
|
111
|
+
|
|
112
|
+
- `state`: Either a callable that returns state data, or a mapping/dictionary containing the state data directly
|
|
113
|
+
|
|
114
|
+
Returns `self` for method chaining.
|
|
115
|
+
|
|
116
|
+
**Examples:**
|
|
117
|
+
```python
|
|
118
|
+
# Direct state setting
|
|
119
|
+
reporter.publish({"status": "running", "count": 42})
|
|
120
|
+
|
|
121
|
+
# Using a callback function
|
|
122
|
+
def get_current_state():
|
|
123
|
+
return {"status": "running", "count": get_count()}
|
|
124
|
+
|
|
125
|
+
reporter.publish(get_current_state)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
#### `start(*, state: Optional[StateSource] = None, host: Optional[str] = None, port: Optional[int] = None, log_level: Optional[Union[int, str]] = None, blocking: bool = False) -> None`
|
|
129
|
+
|
|
130
|
+
Start the status reporter.
|
|
131
|
+
|
|
132
|
+
- `state`: Initial state or callable to get initial state
|
|
133
|
+
- `host`: Host address to bind to (default: `"0.0.0.0"`)
|
|
134
|
+
- `port`: Port number to listen on (default: `4242`)
|
|
135
|
+
- `log_level`: Logging level for uvicorn
|
|
136
|
+
- `blocking`: If True, blocks until server stops (default: `False`)
|
|
137
|
+
|
|
138
|
+
#### `stop() -> None`
|
|
139
|
+
|
|
140
|
+
Stop the status reporter and clean up resources.
|
|
141
|
+
|
|
142
|
+
#### `reset() -> None`
|
|
143
|
+
|
|
144
|
+
Reset the status reporter to its initial state.
|
|
145
|
+
|
|
146
|
+
### AsyncStatusReporter
|
|
147
|
+
|
|
148
|
+
Async version of StatusReporter for use with FastAPI and other async frameworks.
|
|
149
|
+
|
|
150
|
+
#### `AsyncStatusReporter(endpoint: Optional[str] = None)`
|
|
151
|
+
|
|
152
|
+
- `endpoint`: Custom endpoint path for the status API
|
|
153
|
+
|
|
154
|
+
#### `async publish(state: AsyncStateType) -> None`
|
|
155
|
+
|
|
156
|
+
Set the application state asynchronously.
|
|
157
|
+
|
|
158
|
+
- `state`: Either a callable that returns state data (can be async), or a mapping/dictionary containing the state data directly
|
|
159
|
+
|
|
160
|
+
**Examples:**
|
|
161
|
+
```python
|
|
162
|
+
# Direct state setting
|
|
163
|
+
await reporter.publish({"status": "running", "count": 42})
|
|
164
|
+
|
|
165
|
+
# Using an async callback function
|
|
166
|
+
async def get_current_state():
|
|
167
|
+
return {"status": "running", "count": await get_count()}
|
|
168
|
+
await reporter.publish(get_current_state)
|
|
169
|
+
|
|
170
|
+
# Using a sync callback function
|
|
171
|
+
def get_current_state():
|
|
172
|
+
return {"status": "running", "count": get_count()}
|
|
173
|
+
await reporter.publish(get_current_state)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
#### `async start(*, state: Optional[AsyncStateType] = None, host: Optional[str] = None, port: Optional[int] = None, update_interval: int = 5) -> None`
|
|
177
|
+
|
|
178
|
+
Start the async status reporter.
|
|
179
|
+
|
|
180
|
+
- `state`: Initial state or callable to get initial state
|
|
181
|
+
- `host`: Host address to bind to (default: `"0.0.0.0"`)
|
|
182
|
+
- `port`: Port number to listen on (default: `4242`)
|
|
183
|
+
- `update_interval`: Interval in seconds for automatic state updates (default: `5`)
|
|
184
|
+
|
|
185
|
+
#### `async stop() -> None`
|
|
186
|
+
|
|
187
|
+
Stop the async status reporter.
|
|
188
|
+
|
|
189
|
+
#### `reset() -> None`
|
|
190
|
+
|
|
191
|
+
Reset the status reporter to its initial state.
|
|
192
|
+
|
|
193
|
+
## Singleton Functions
|
|
194
|
+
|
|
195
|
+
For simple use cases, you can use the singleton functions:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
import monsta
|
|
199
|
+
|
|
200
|
+
# Start monitoring with singleton
|
|
201
|
+
monsta.start(state={"status": "running"}, blocking=False)
|
|
202
|
+
|
|
203
|
+
# Update state
|
|
204
|
+
monsta.publish({"status": "running", "requests": 42})
|
|
205
|
+
|
|
206
|
+
# Stop monitoring
|
|
207
|
+
monsta.stop()
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Configuration
|
|
211
|
+
|
|
212
|
+
### Environment Variables
|
|
213
|
+
|
|
214
|
+
Monsta respects standard uvicorn environment variables for configuration.
|
|
215
|
+
|
|
216
|
+
### Customization
|
|
217
|
+
|
|
218
|
+
You can customize the monitoring behavior:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
# Custom endpoint
|
|
222
|
+
mon = MonitoringAgent(endpoint="/custom/monitoring/path")
|
|
223
|
+
|
|
224
|
+
# Custom host and port
|
|
225
|
+
mon.start(host="127.0.0.1", port=8080)
|
|
226
|
+
|
|
227
|
+
# Custom update interval (async only)
|
|
228
|
+
await async_mon.start(update_interval=10) # Update every 10 seconds
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Monitoring Endpoint
|
|
232
|
+
|
|
233
|
+
The monitoring endpoint returns a JSON response with the following structure:
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"internal": {
|
|
238
|
+
"uptime": 12345
|
|
239
|
+
},
|
|
240
|
+
"state": {
|
|
241
|
+
"status": "running",
|
|
242
|
+
"requests": 42,
|
|
243
|
+
"custom_metrics": {}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- `internal.uptime`: Automatic uptime tracking in seconds
|
|
249
|
+
- `state`: Your application-specific state data
|
|
250
|
+
|
|
251
|
+
## Examples
|
|
252
|
+
|
|
253
|
+
See the `examples/` directory for complete working examples:
|
|
254
|
+
|
|
255
|
+
- `embedded.py`: Basic FastAPI integration
|
|
256
|
+
- `embedded_async.py`: Async FastAPI integration
|
|
257
|
+
- `singleton.py`: Singleton usage example
|
|
258
|
+
- `standalone.py`: Standalone monitoring server
|
|
259
|
+
|
|
260
|
+
## Development
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
# Install development dependencies using uv
|
|
264
|
+
uv sync --dev
|
|
265
|
+
|
|
266
|
+
# Run tests
|
|
267
|
+
uv run pytest
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Project Structure
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
src/monsta/
|
|
274
|
+
├── __init__.py # Main package exports
|
|
275
|
+
├── mon.py # Synchronous StatusReporter implementation
|
|
276
|
+
├── aiomon.py # Async StatusReporter implementation
|
|
277
|
+
└── py.typed # Type annotations
|
|
278
|
+
|
|
279
|
+
examples/
|
|
280
|
+
├── embedded.py # Basic FastAPI integration example
|
|
281
|
+
├── embedded_async.py # Async FastAPI integration example
|
|
282
|
+
├── singleton.py # Singleton usage example
|
|
283
|
+
└── standalone.py # Standalone server example
|
|
284
|
+
|
|
285
|
+
tests/
|
|
286
|
+
├── test_sync.py # Synchronous StatusReporter tests
|
|
287
|
+
└── test_async.py # AsyncStatusReporter tests
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## License
|
|
291
|
+
|
|
292
|
+
[BSD 3-Clause License](./LICENSE)
|
|
293
|
+
|
|
294
|
+
## Support
|
|
295
|
+
|
|
296
|
+
For issues, questions, or contributions, please open an issue on the GitHub repository.
|
monsta-0.1.0/README.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# Monsta - Status Reporting REST API for Python Applications
|
|
2
|
+
|
|
3
|
+
**Monsta** (short for "Monitoring State") is a lightweight library for Python applications that provides a REST API endpoint for exposing application state and metrics. It's designed for seamless integration with FastAPI.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Simple Integration**: Add monitoring to your application with just a few lines of code
|
|
8
|
+
- **Async Support**: Native async/await support for FastAPI
|
|
9
|
+
- **Thread-Safe**: Built-in thread safety for concurrent access
|
|
10
|
+
- **Flexible State Management**: Support for both direct state values and callback functions
|
|
11
|
+
- **Built-in Metrics**: Automatic uptime tracking (and possibly other internal metrics)
|
|
12
|
+
- **Customizable**: Configure endpoint paths, ports, and update intervals
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install monsta
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### Basic Usage
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from monsta import StatusReporter
|
|
26
|
+
|
|
27
|
+
# Create status reporter
|
|
28
|
+
mon = StatusReporter()
|
|
29
|
+
|
|
30
|
+
# Set application state
|
|
31
|
+
mon.publish({"status": "running", "version": "1.0.0"})
|
|
32
|
+
|
|
33
|
+
# Start status reporting server (blocking)
|
|
34
|
+
mon.start(blocking=True)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### FastAPI Integration
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from fastapi import FastAPI
|
|
41
|
+
from monsta import StatusReporter
|
|
42
|
+
|
|
43
|
+
app = FastAPI()
|
|
44
|
+
|
|
45
|
+
# Create and integrate status reporter
|
|
46
|
+
mon = StatusReporter(endpoint="/api/v1/monitoring")
|
|
47
|
+
app.include_router(mon.router)
|
|
48
|
+
|
|
49
|
+
# Update state during application lifecycle
|
|
50
|
+
mon.publish({"status": "running", "requests": 0})
|
|
51
|
+
|
|
52
|
+
# Start FastAPI app
|
|
53
|
+
# Status reporting will be available at /api/v1/monitoring
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Async FastAPI Integration
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from contextlib import asynccontextmanager
|
|
60
|
+
from fastapi import FastAPI
|
|
61
|
+
from monsta import AsyncStatusReporter
|
|
62
|
+
|
|
63
|
+
@asynccontextmanager
|
|
64
|
+
async def lifespan(app: FastAPI):
|
|
65
|
+
# Initialize status reporting
|
|
66
|
+
app.state.mon = AsyncStatusReporter(endpoint="/api/v1/state")
|
|
67
|
+
app.include_router(app.state.mon.router)
|
|
68
|
+
|
|
69
|
+
# Start async status reporting
|
|
70
|
+
await app.state.mon.start(state={"status": "starting"})
|
|
71
|
+
|
|
72
|
+
yield
|
|
73
|
+
|
|
74
|
+
# Clean up
|
|
75
|
+
await app.state.mon.stop()
|
|
76
|
+
|
|
77
|
+
app = FastAPI(lifespan=lifespan)
|
|
78
|
+
|
|
79
|
+
@app.get("/")
|
|
80
|
+
async def root():
|
|
81
|
+
# Update status asynchronously
|
|
82
|
+
await app.state.mon.publish({"status": "running", "requests": 1})
|
|
83
|
+
return {"message": "Hello World"}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## API Reference
|
|
87
|
+
|
|
88
|
+
### StatusReporter
|
|
89
|
+
|
|
90
|
+
The main synchronous status reporter class.
|
|
91
|
+
|
|
92
|
+
#### `StatusReporter(endpoint: Optional[str] = None)`
|
|
93
|
+
|
|
94
|
+
- `endpoint`: Custom endpoint path for the status API (default: `/mon/v1/state`)
|
|
95
|
+
|
|
96
|
+
#### `publish(state: StateSource) -> Self`
|
|
97
|
+
|
|
98
|
+
Set the application state.
|
|
99
|
+
|
|
100
|
+
- `state`: Either a callable that returns state data, or a mapping/dictionary containing the state data directly
|
|
101
|
+
|
|
102
|
+
Returns `self` for method chaining.
|
|
103
|
+
|
|
104
|
+
**Examples:**
|
|
105
|
+
```python
|
|
106
|
+
# Direct state setting
|
|
107
|
+
reporter.publish({"status": "running", "count": 42})
|
|
108
|
+
|
|
109
|
+
# Using a callback function
|
|
110
|
+
def get_current_state():
|
|
111
|
+
return {"status": "running", "count": get_count()}
|
|
112
|
+
|
|
113
|
+
reporter.publish(get_current_state)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
#### `start(*, state: Optional[StateSource] = None, host: Optional[str] = None, port: Optional[int] = None, log_level: Optional[Union[int, str]] = None, blocking: bool = False) -> None`
|
|
117
|
+
|
|
118
|
+
Start the status reporter.
|
|
119
|
+
|
|
120
|
+
- `state`: Initial state or callable to get initial state
|
|
121
|
+
- `host`: Host address to bind to (default: `"0.0.0.0"`)
|
|
122
|
+
- `port`: Port number to listen on (default: `4242`)
|
|
123
|
+
- `log_level`: Logging level for uvicorn
|
|
124
|
+
- `blocking`: If True, blocks until server stops (default: `False`)
|
|
125
|
+
|
|
126
|
+
#### `stop() -> None`
|
|
127
|
+
|
|
128
|
+
Stop the status reporter and clean up resources.
|
|
129
|
+
|
|
130
|
+
#### `reset() -> None`
|
|
131
|
+
|
|
132
|
+
Reset the status reporter to its initial state.
|
|
133
|
+
|
|
134
|
+
### AsyncStatusReporter
|
|
135
|
+
|
|
136
|
+
Async version of StatusReporter for use with FastAPI and other async frameworks.
|
|
137
|
+
|
|
138
|
+
#### `AsyncStatusReporter(endpoint: Optional[str] = None)`
|
|
139
|
+
|
|
140
|
+
- `endpoint`: Custom endpoint path for the status API
|
|
141
|
+
|
|
142
|
+
#### `async publish(state: AsyncStateType) -> None`
|
|
143
|
+
|
|
144
|
+
Set the application state asynchronously.
|
|
145
|
+
|
|
146
|
+
- `state`: Either a callable that returns state data (can be async), or a mapping/dictionary containing the state data directly
|
|
147
|
+
|
|
148
|
+
**Examples:**
|
|
149
|
+
```python
|
|
150
|
+
# Direct state setting
|
|
151
|
+
await reporter.publish({"status": "running", "count": 42})
|
|
152
|
+
|
|
153
|
+
# Using an async callback function
|
|
154
|
+
async def get_current_state():
|
|
155
|
+
return {"status": "running", "count": await get_count()}
|
|
156
|
+
await reporter.publish(get_current_state)
|
|
157
|
+
|
|
158
|
+
# Using a sync callback function
|
|
159
|
+
def get_current_state():
|
|
160
|
+
return {"status": "running", "count": get_count()}
|
|
161
|
+
await reporter.publish(get_current_state)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
#### `async start(*, state: Optional[AsyncStateType] = None, host: Optional[str] = None, port: Optional[int] = None, update_interval: int = 5) -> None`
|
|
165
|
+
|
|
166
|
+
Start the async status reporter.
|
|
167
|
+
|
|
168
|
+
- `state`: Initial state or callable to get initial state
|
|
169
|
+
- `host`: Host address to bind to (default: `"0.0.0.0"`)
|
|
170
|
+
- `port`: Port number to listen on (default: `4242`)
|
|
171
|
+
- `update_interval`: Interval in seconds for automatic state updates (default: `5`)
|
|
172
|
+
|
|
173
|
+
#### `async stop() -> None`
|
|
174
|
+
|
|
175
|
+
Stop the async status reporter.
|
|
176
|
+
|
|
177
|
+
#### `reset() -> None`
|
|
178
|
+
|
|
179
|
+
Reset the status reporter to its initial state.
|
|
180
|
+
|
|
181
|
+
## Singleton Functions
|
|
182
|
+
|
|
183
|
+
For simple use cases, you can use the singleton functions:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
import monsta
|
|
187
|
+
|
|
188
|
+
# Start monitoring with singleton
|
|
189
|
+
monsta.start(state={"status": "running"}, blocking=False)
|
|
190
|
+
|
|
191
|
+
# Update state
|
|
192
|
+
monsta.publish({"status": "running", "requests": 42})
|
|
193
|
+
|
|
194
|
+
# Stop monitoring
|
|
195
|
+
monsta.stop()
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Configuration
|
|
199
|
+
|
|
200
|
+
### Environment Variables
|
|
201
|
+
|
|
202
|
+
Monsta respects standard uvicorn environment variables for configuration.
|
|
203
|
+
|
|
204
|
+
### Customization
|
|
205
|
+
|
|
206
|
+
You can customize the monitoring behavior:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
# Custom endpoint
|
|
210
|
+
mon = MonitoringAgent(endpoint="/custom/monitoring/path")
|
|
211
|
+
|
|
212
|
+
# Custom host and port
|
|
213
|
+
mon.start(host="127.0.0.1", port=8080)
|
|
214
|
+
|
|
215
|
+
# Custom update interval (async only)
|
|
216
|
+
await async_mon.start(update_interval=10) # Update every 10 seconds
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Monitoring Endpoint
|
|
220
|
+
|
|
221
|
+
The monitoring endpoint returns a JSON response with the following structure:
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"internal": {
|
|
226
|
+
"uptime": 12345
|
|
227
|
+
},
|
|
228
|
+
"state": {
|
|
229
|
+
"status": "running",
|
|
230
|
+
"requests": 42,
|
|
231
|
+
"custom_metrics": {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
- `internal.uptime`: Automatic uptime tracking in seconds
|
|
237
|
+
- `state`: Your application-specific state data
|
|
238
|
+
|
|
239
|
+
## Examples
|
|
240
|
+
|
|
241
|
+
See the `examples/` directory for complete working examples:
|
|
242
|
+
|
|
243
|
+
- `embedded.py`: Basic FastAPI integration
|
|
244
|
+
- `embedded_async.py`: Async FastAPI integration
|
|
245
|
+
- `singleton.py`: Singleton usage example
|
|
246
|
+
- `standalone.py`: Standalone monitoring server
|
|
247
|
+
|
|
248
|
+
## Development
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
# Install development dependencies using uv
|
|
252
|
+
uv sync --dev
|
|
253
|
+
|
|
254
|
+
# Run tests
|
|
255
|
+
uv run pytest
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Project Structure
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
src/monsta/
|
|
262
|
+
├── __init__.py # Main package exports
|
|
263
|
+
├── mon.py # Synchronous StatusReporter implementation
|
|
264
|
+
├── aiomon.py # Async StatusReporter implementation
|
|
265
|
+
└── py.typed # Type annotations
|
|
266
|
+
|
|
267
|
+
examples/
|
|
268
|
+
├── embedded.py # Basic FastAPI integration example
|
|
269
|
+
├── embedded_async.py # Async FastAPI integration example
|
|
270
|
+
├── singleton.py # Singleton usage example
|
|
271
|
+
└── standalone.py # Standalone server example
|
|
272
|
+
|
|
273
|
+
tests/
|
|
274
|
+
├── test_sync.py # Synchronous StatusReporter tests
|
|
275
|
+
└── test_async.py # AsyncStatusReporter tests
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
[BSD 3-Clause License](./LICENSE)
|
|
281
|
+
|
|
282
|
+
## Support
|
|
283
|
+
|
|
284
|
+
For issues, questions, or contributions, please open an issue on the GitHub repository.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "monsta"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Simple monitoring REST API for Python applications"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Stefan Schönberger", email = "stefan@sniner.dev" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastapi>=0.133.1",
|
|
12
|
+
"pydantic>=2.12.5",
|
|
13
|
+
"uvicorn>=0.41.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.10.6,<0.11.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"basedpyright>=1.38.2",
|
|
23
|
+
"pytest>=9.0.2",
|
|
24
|
+
"ruff>=0.15.3",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
addopts = "-ra -q"
|
|
29
|
+
testpaths = [
|
|
30
|
+
"tests",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.pyright]
|
|
34
|
+
typeCheckingMode = "standard"
|
|
35
|
+
useLibraryCodeForTypes = true
|
|
36
|
+
venvPath = "."
|
|
37
|
+
venv = ".venv"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 96
|
|
41
|
+
ignore = ["E402"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
per-file-ignores = { "__init__.py" = ["F401"] }
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Callable, Mapping, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .mon import (
|
|
9
|
+
DEFAULT_HOST,
|
|
10
|
+
DEFAULT_PORT,
|
|
11
|
+
UPDATE_HOLDOFF,
|
|
12
|
+
StateCallback,
|
|
13
|
+
StatusReporter,
|
|
14
|
+
_now,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
AsyncStateCallback = Callable[[], Any]
|
|
20
|
+
AsyncStateType = Union[AsyncStateCallback, StateCallback, Mapping]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncStatusReporter:
|
|
24
|
+
"""Async version of StatusReporter for use with FastAPI and other async frameworks.
|
|
25
|
+
|
|
26
|
+
This class provides the same status reporting functionality as StatusReporter but with
|
|
27
|
+
native async/await support, making it ideal for FastAPI applications.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, *, endpoint: Optional[str] = None):
|
|
31
|
+
"""Initialize an async status reporter.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
endpoint: Custom endpoint path for the status API
|
|
35
|
+
"""
|
|
36
|
+
# Create a synchronous StatusReporter for state management
|
|
37
|
+
self._sync_agent = StatusReporter(endpoint=endpoint)
|
|
38
|
+
|
|
39
|
+
# Async-specific components
|
|
40
|
+
self._async_state_callback: Optional[AsyncStateCallback] = None
|
|
41
|
+
self._async_update_task: Optional[asyncio.Task] = None
|
|
42
|
+
|
|
43
|
+
# Use the sync agent's router
|
|
44
|
+
self.router = self._sync_agent.router
|
|
45
|
+
|
|
46
|
+
def reset(self) -> None:
|
|
47
|
+
"""Reset the monitoring agent to its initial state."""
|
|
48
|
+
self._sync_agent.reset()
|
|
49
|
+
|
|
50
|
+
async def publish(self, state: AsyncStateType) -> None:
|
|
51
|
+
"""Set the application state asynchronously.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
state: Either a callable that returns state data (can be async),
|
|
55
|
+
or a mapping/dictionary containing the state data directly
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
# Direct state setting
|
|
59
|
+
await agent.publish({"status": "running", "count": 42})
|
|
60
|
+
|
|
61
|
+
# Using an async callback function
|
|
62
|
+
async def get_current_state():
|
|
63
|
+
return {"status": "running", "count": await get_count()}
|
|
64
|
+
await agent.publish(get_current_state)
|
|
65
|
+
|
|
66
|
+
# Using a sync callback function
|
|
67
|
+
def get_current_state():
|
|
68
|
+
return {"status": "running", "count": get_count()}
|
|
69
|
+
await agent.publish(get_current_state)
|
|
70
|
+
"""
|
|
71
|
+
if inspect.iscoroutinefunction(state):
|
|
72
|
+
self._async_state_callback = state
|
|
73
|
+
self._sync_agent._state_callback = None
|
|
74
|
+
_state = await state()
|
|
75
|
+
_logger.debug("Async state callback registered and executed")
|
|
76
|
+
self._sync_agent._set_state(_state)
|
|
77
|
+
else:
|
|
78
|
+
self._async_state_callback = None
|
|
79
|
+
self._sync_agent.publish(state)
|
|
80
|
+
|
|
81
|
+
async def _update_state_async(self) -> None:
|
|
82
|
+
"""Update the monitoring state if sufficient time has passed.
|
|
83
|
+
|
|
84
|
+
This method implements rate-limiting to prevent excessive state updates.
|
|
85
|
+
Only updates the state if UPDATE_HOLDOFF seconds have passed since
|
|
86
|
+
the last update.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
if self._async_state_callback:
|
|
90
|
+
now = _now()
|
|
91
|
+
if self._sync_agent._update_deferred(now):
|
|
92
|
+
return
|
|
93
|
+
try:
|
|
94
|
+
self._sync_agent._update_internal_state(now)
|
|
95
|
+
_state = await self._async_state_callback()
|
|
96
|
+
self._sync_agent._set_state(_state)
|
|
97
|
+
except ValueError as ve:
|
|
98
|
+
_logger.error("Invalid state value during async update: %s", ve)
|
|
99
|
+
except RuntimeError as re:
|
|
100
|
+
_logger.error("Runtime error during async state update: %s", re)
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
_logger.error("Unexpected error during async state update: %s", exc)
|
|
103
|
+
else:
|
|
104
|
+
self._sync_agent._update_state()
|
|
105
|
+
|
|
106
|
+
async def start(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
state: Optional[AsyncStateType] = None,
|
|
110
|
+
host: Optional[str] = None,
|
|
111
|
+
port: Optional[int] = None,
|
|
112
|
+
update_interval: int = UPDATE_HOLDOFF,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Start the async monitoring agent.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
app_state: Initial state or callable to get initial state
|
|
118
|
+
host: Host address to bind to
|
|
119
|
+
port: Port number to listen on
|
|
120
|
+
update_interval: Interval in seconds for automatic state updates
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
if self._sync_agent._worker_alive():
|
|
124
|
+
raise RuntimeError("Monitoring agent already running")
|
|
125
|
+
|
|
126
|
+
self.reset()
|
|
127
|
+
await self.publish(state or {})
|
|
128
|
+
|
|
129
|
+
actual_host = host or DEFAULT_HOST
|
|
130
|
+
actual_port = port or DEFAULT_PORT
|
|
131
|
+
|
|
132
|
+
# Start the async update task
|
|
133
|
+
self._async_update_task = asyncio.create_task(
|
|
134
|
+
self._periodic_update_async(update_interval)
|
|
135
|
+
)
|
|
136
|
+
_logger.info(
|
|
137
|
+
"Async monitoring agent started successfully on %s:%d", actual_host, actual_port
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
_logger.error("Failed to start async monitoring agent: %s", exc)
|
|
142
|
+
raise
|
|
143
|
+
|
|
144
|
+
async def _periodic_update_async(self, interval: int) -> None:
|
|
145
|
+
"""Background task for periodic state updates in async mode."""
|
|
146
|
+
try:
|
|
147
|
+
while True:
|
|
148
|
+
await asyncio.sleep(interval)
|
|
149
|
+
await self._update_state_async()
|
|
150
|
+
except asyncio.CancelledError:
|
|
151
|
+
_logger.info("Async update task cancelled")
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
_logger.error("Async update task failed: %s", exc)
|
|
154
|
+
|
|
155
|
+
async def stop(self) -> None:
|
|
156
|
+
"""Stop the async monitoring agent."""
|
|
157
|
+
try:
|
|
158
|
+
# Cancel async update task if running
|
|
159
|
+
if self._async_update_task:
|
|
160
|
+
self._async_update_task.cancel()
|
|
161
|
+
try:
|
|
162
|
+
await self._async_update_task
|
|
163
|
+
except asyncio.CancelledError:
|
|
164
|
+
pass
|
|
165
|
+
self._async_update_task = None
|
|
166
|
+
_logger.info("Async update task cancelled successfully")
|
|
167
|
+
|
|
168
|
+
# Stop the sync components
|
|
169
|
+
if self._sync_agent._uvicorn_server:
|
|
170
|
+
self._sync_agent._uvicorn_server.should_exit = True
|
|
171
|
+
_logger.info("Signaled uvicorn server to exit")
|
|
172
|
+
|
|
173
|
+
if self._sync_agent._worker_thread:
|
|
174
|
+
self._sync_agent._worker_thread.join(timeout=5)
|
|
175
|
+
if self._sync_agent._worker_thread.is_alive():
|
|
176
|
+
_logger.warning("Monitoring thread did not terminate within timeout")
|
|
177
|
+
self._sync_agent._worker_thread = None
|
|
178
|
+
_logger.info("Monitoring thread joined successfully")
|
|
179
|
+
|
|
180
|
+
self._sync_agent._uvicorn_server = None
|
|
181
|
+
_logger.info("Async monitoring agent stopped successfully")
|
|
182
|
+
except RuntimeError as re:
|
|
183
|
+
_logger.error("Runtime error during async agent shutdown: %s", re)
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
_logger.error("Unexpected error during async agent shutdown: %s", exc)
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Callable, Mapping, Optional, Self, Union
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
from fastapi import APIRouter, FastAPI
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
_logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
UPDATE_HOLDOFF = 5 # seconds: rate limit for state updates
|
|
15
|
+
DEFAULT_PORT = 4242
|
|
16
|
+
DEFAULT_HOST = "0.0.0.0"
|
|
17
|
+
|
|
18
|
+
DEFAULT_ENDPOINT = "/mon/v1/state"
|
|
19
|
+
|
|
20
|
+
StateValue = Mapping[str, Any]
|
|
21
|
+
StateCallback = Callable[[], StateValue]
|
|
22
|
+
StateSource = Union[StateCallback, StateValue]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InternalState(BaseModel):
|
|
26
|
+
uptime: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MonitoringState(BaseModel):
|
|
30
|
+
# "internal" holds library-generated stats (like uptime)
|
|
31
|
+
internal: InternalState = Field(default_factory=InternalState)
|
|
32
|
+
# "state" holds the application state
|
|
33
|
+
state: StateValue = {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _now() -> float:
|
|
37
|
+
return time.monotonic()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StatusReporter:
|
|
41
|
+
def __init__(self, *, endpoint: Optional[str] = None):
|
|
42
|
+
self._state: MonitoringState = MonitoringState()
|
|
43
|
+
self._state_lock: threading.RLock = threading.RLock()
|
|
44
|
+
self._state_callback: Optional[StateCallback] = None
|
|
45
|
+
self._update_time: float = 0.0
|
|
46
|
+
self._startup_time: float = _now()
|
|
47
|
+
|
|
48
|
+
self._worker_thread: Optional[threading.Thread] = None
|
|
49
|
+
self._uvicorn_server: Optional[uvicorn.Server] = None
|
|
50
|
+
|
|
51
|
+
self.router = APIRouter()
|
|
52
|
+
self.router.add_api_route(
|
|
53
|
+
path=endpoint or DEFAULT_ENDPOINT,
|
|
54
|
+
endpoint=self._api_endpoint,
|
|
55
|
+
methods=["GET"],
|
|
56
|
+
response_model=MonitoringState,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def reset(self) -> None:
|
|
60
|
+
"""Reset the monitoring agent to its initial state.
|
|
61
|
+
|
|
62
|
+
Clears all state variables and resets timers, but does not stop
|
|
63
|
+
any running threads. Use stop() method to stop the monitoring agent.
|
|
64
|
+
|
|
65
|
+
Note: This method does NOT kill the monitoring thread.
|
|
66
|
+
Use stop() or restart() for thread management.
|
|
67
|
+
"""
|
|
68
|
+
with self._state_lock:
|
|
69
|
+
self._state = MonitoringState()
|
|
70
|
+
self._update_time = 0.0
|
|
71
|
+
self._startup_time = _now()
|
|
72
|
+
_logger.debug("State reset")
|
|
73
|
+
|
|
74
|
+
def _set_state(self, state: StateValue) -> None:
|
|
75
|
+
"""Internal method to update the state with thread safety.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
state: The state data to set, as a mapping/dictionary
|
|
79
|
+
"""
|
|
80
|
+
with self._state_lock:
|
|
81
|
+
self._state.state = state
|
|
82
|
+
_logger.debug("State updated: %r", state)
|
|
83
|
+
|
|
84
|
+
def publish(self, state: StateSource) -> Self:
|
|
85
|
+
"""Set the application state.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
state: Either a callable that returns state data, or a mapping/dictionary
|
|
89
|
+
containing the state data directly
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Self: Returns self for method chaining
|
|
93
|
+
|
|
94
|
+
Examples:
|
|
95
|
+
# Direct state setting
|
|
96
|
+
agent.publish({"status": "running", "count": 42})
|
|
97
|
+
|
|
98
|
+
# Using a callback function
|
|
99
|
+
def get_current_state():
|
|
100
|
+
return {"status": "running", "count": get_count()}
|
|
101
|
+
agent.publish(get_current_state)
|
|
102
|
+
"""
|
|
103
|
+
_state: Mapping[str, Any] = {}
|
|
104
|
+
if callable(state):
|
|
105
|
+
self._state_callback = state
|
|
106
|
+
_state = state()
|
|
107
|
+
_logger.debug("State callback registered and executed")
|
|
108
|
+
else:
|
|
109
|
+
self._state_callback = None
|
|
110
|
+
if isinstance(state, Mapping):
|
|
111
|
+
_state = dict(state)
|
|
112
|
+
else:
|
|
113
|
+
_logger.warning("Unsupported state value: %r", state)
|
|
114
|
+
self._set_state(_state)
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def _uptime(self, now: Optional[float] = None) -> int:
|
|
118
|
+
return int((now or _now()) - self._startup_time)
|
|
119
|
+
|
|
120
|
+
def _update_internal_state(self, now: Optional[float] = None) -> None:
|
|
121
|
+
"""Update the internal state with global monitoring information.
|
|
122
|
+
|
|
123
|
+
Updates system-level monitoring data like uptime.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
self._state.internal.uptime = self._uptime(now)
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
_logger.error("Failed to update internal state: %s", exc)
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
def _update_deferred(self, now: float) -> bool:
|
|
132
|
+
if now - self._update_time < UPDATE_HOLDOFF:
|
|
133
|
+
return True
|
|
134
|
+
self._update_time = now
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
def _update_state(self) -> None:
|
|
138
|
+
"""Update the monitoring state if sufficient time has passed.
|
|
139
|
+
|
|
140
|
+
This method implements rate-limiting to prevent excessive state updates.
|
|
141
|
+
Only updates the state if UPDATE_HOLDOFF seconds have passed since
|
|
142
|
+
the last update.
|
|
143
|
+
"""
|
|
144
|
+
now = _now()
|
|
145
|
+
if self._update_deferred(now):
|
|
146
|
+
return
|
|
147
|
+
try:
|
|
148
|
+
self._update_internal_state(now)
|
|
149
|
+
if self._state_callback:
|
|
150
|
+
self._set_state(self._state_callback())
|
|
151
|
+
except ValueError as ve:
|
|
152
|
+
_logger.error("Invalid state value during update: %s", ve)
|
|
153
|
+
except RuntimeError as re:
|
|
154
|
+
_logger.error("Runtime error during state update: %s", re)
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
_logger.error("Unexpected error during state update: %s", exc)
|
|
157
|
+
|
|
158
|
+
def _api_endpoint(self) -> MonitoringState:
|
|
159
|
+
"""Get the current monitoring status.
|
|
160
|
+
|
|
161
|
+
Returns the complete monitoring state including both internal
|
|
162
|
+
monitoring data and application state.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Dict[str, Any]: Dictionary containing 'internal' and 'state' keys
|
|
166
|
+
|
|
167
|
+
Note:
|
|
168
|
+
This method is called by the FastAPI endpoint and implements
|
|
169
|
+
error handling to ensure a response is always returned.
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
with self._state_lock:
|
|
173
|
+
self._update_state()
|
|
174
|
+
return self._state
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
_logger.error("Failed to provide monitoring status: %s", exc)
|
|
177
|
+
# Return minimal state even on error
|
|
178
|
+
return MonitoringState(internal=InternalState(uptime=self._uptime()))
|
|
179
|
+
|
|
180
|
+
def _worker(
|
|
181
|
+
self,
|
|
182
|
+
host: str,
|
|
183
|
+
port: int,
|
|
184
|
+
log_level: Optional[Union[int, str]] = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Main monitoring worker thread function.
|
|
187
|
+
|
|
188
|
+
Starts the FastAPI application with uvicorn server and handles
|
|
189
|
+
the monitoring endpoint.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
host: Host address to bind to
|
|
193
|
+
port: Port number to listen on
|
|
194
|
+
log_level: Logging level for uvicorn
|
|
195
|
+
"""
|
|
196
|
+
try:
|
|
197
|
+
app = FastAPI()
|
|
198
|
+
app.include_router(self.router)
|
|
199
|
+
|
|
200
|
+
config = uvicorn.Config(
|
|
201
|
+
app=app,
|
|
202
|
+
host=host,
|
|
203
|
+
port=port,
|
|
204
|
+
log_level=log_level,
|
|
205
|
+
)
|
|
206
|
+
self._uvicorn_server = uvicorn.Server(config)
|
|
207
|
+
|
|
208
|
+
_logger.info("Starting monitoring worker on %s:%d", host, port)
|
|
209
|
+
|
|
210
|
+
# Update state once on startup
|
|
211
|
+
self._update_state()
|
|
212
|
+
|
|
213
|
+
self._uvicorn_server.run()
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
_logger.error("Monitoring worker failed to start: %s", exc)
|
|
216
|
+
raise
|
|
217
|
+
|
|
218
|
+
def _worker_alive(self) -> bool:
|
|
219
|
+
return bool(self._worker_thread and self._worker_thread.is_alive())
|
|
220
|
+
|
|
221
|
+
def start(
|
|
222
|
+
self,
|
|
223
|
+
*,
|
|
224
|
+
state: Optional[StateSource] = None,
|
|
225
|
+
host: Optional[str] = None,
|
|
226
|
+
port: Optional[int] = None,
|
|
227
|
+
log_level: Optional[Union[int, str]] = None,
|
|
228
|
+
blocking: bool = False,
|
|
229
|
+
) -> None:
|
|
230
|
+
try:
|
|
231
|
+
if self._worker_alive():
|
|
232
|
+
raise RuntimeError("Monitoring worker already running")
|
|
233
|
+
|
|
234
|
+
self.reset()
|
|
235
|
+
self.publish(state or {})
|
|
236
|
+
|
|
237
|
+
actual_host = host or DEFAULT_HOST
|
|
238
|
+
actual_port = port or DEFAULT_PORT
|
|
239
|
+
|
|
240
|
+
self._worker_thread = threading.Thread(
|
|
241
|
+
target=self._worker,
|
|
242
|
+
args=(actual_host, actual_port, log_level),
|
|
243
|
+
daemon=True,
|
|
244
|
+
)
|
|
245
|
+
self._worker_thread.start()
|
|
246
|
+
_logger.debug("Monitoring worker started successfully")
|
|
247
|
+
|
|
248
|
+
if blocking:
|
|
249
|
+
try:
|
|
250
|
+
self._worker_thread.join()
|
|
251
|
+
except KeyboardInterrupt:
|
|
252
|
+
_logger.debug("Received keyboard interrupt, stopping monitoring worker")
|
|
253
|
+
self.stop()
|
|
254
|
+
raise
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
_logger.error("Failed to start monitoring worker: %s", exc)
|
|
257
|
+
raise
|
|
258
|
+
|
|
259
|
+
def stop(self) -> None:
|
|
260
|
+
try:
|
|
261
|
+
if self._uvicorn_server:
|
|
262
|
+
self._uvicorn_server.should_exit = True
|
|
263
|
+
_logger.debug("Signaled uvicorn server to exit")
|
|
264
|
+
|
|
265
|
+
if self._worker_thread:
|
|
266
|
+
self._worker_thread.join(timeout=5)
|
|
267
|
+
if self._worker_thread.is_alive():
|
|
268
|
+
_logger.warning("Monitoring worker did not terminate within timeout")
|
|
269
|
+
self._worker_thread = None
|
|
270
|
+
_logger.debug("Monitoring worker joined successfully")
|
|
271
|
+
|
|
272
|
+
self._uvicorn_server = None
|
|
273
|
+
_logger.debug("Monitoring worker stopped successfully")
|
|
274
|
+
except RuntimeError as re:
|
|
275
|
+
_logger.error("Runtime error during worker shutdown: %s", re)
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
_logger.error("Unexpected error during worker shutdown: %s", exc)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# Singleton instance
|
|
281
|
+
_instance: Optional[StatusReporter] = None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _get_instance() -> StatusReporter:
|
|
285
|
+
global _instance
|
|
286
|
+
if _instance is None:
|
|
287
|
+
_instance = StatusReporter()
|
|
288
|
+
return _instance
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def publish(state: StateSource) -> None:
|
|
292
|
+
_get_instance().publish(state)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def start(
|
|
296
|
+
*,
|
|
297
|
+
state: Optional[StateSource] = None,
|
|
298
|
+
host: Optional[str] = None,
|
|
299
|
+
port: Optional[int] = None,
|
|
300
|
+
log_level: Optional[Union[int, str]] = None,
|
|
301
|
+
blocking: bool = False,
|
|
302
|
+
) -> None:
|
|
303
|
+
_get_instance().start(
|
|
304
|
+
state=state,
|
|
305
|
+
port=port,
|
|
306
|
+
blocking=blocking,
|
|
307
|
+
host=host,
|
|
308
|
+
log_level=log_level,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def stop() -> None:
|
|
313
|
+
_get_instance().stop()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def reset() -> None:
|
|
317
|
+
"""Reset the global agent state. For testing purposes only."""
|
|
318
|
+
_get_instance().reset()
|
|
File without changes
|