rootly-mcp-server 2.0.15__py3-none-any.whl → 2.1.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.
@@ -0,0 +1,378 @@
1
+ """
2
+ Monitoring, observability, and structured logging for the Rootly MCP Server.
3
+
4
+ This module provides:
5
+ - Structured JSON logging with correlation IDs
6
+ - Request/response logging (sanitized)
7
+ - Performance metrics collection
8
+ - Health check utilities
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import time
14
+ import uuid
15
+ from collections import defaultdict
16
+ from collections.abc import Callable
17
+ from contextlib import contextmanager
18
+ from functools import wraps
19
+ from threading import Lock
20
+ from typing import Any
21
+
22
+ from .security import mask_sensitive_data
23
+
24
+ # Correlation ID storage (thread-local would be better in production)
25
+ _correlation_ids: dict[int, str] = {}
26
+ _correlation_lock = Lock()
27
+
28
+
29
+ def get_correlation_id() -> str:
30
+ """Get or create a correlation ID for the current request."""
31
+ import threading
32
+
33
+ thread_id = threading.get_ident()
34
+
35
+ with _correlation_lock:
36
+ if thread_id not in _correlation_ids:
37
+ _correlation_ids[thread_id] = str(uuid.uuid4())
38
+ return _correlation_ids[thread_id]
39
+
40
+
41
+ def set_correlation_id(correlation_id: str) -> None:
42
+ """Set the correlation ID for the current request."""
43
+ import threading
44
+
45
+ thread_id = threading.get_ident()
46
+
47
+ with _correlation_lock:
48
+ _correlation_ids[thread_id] = correlation_id
49
+
50
+
51
+ def clear_correlation_id() -> None:
52
+ """Clear the correlation ID for the current request."""
53
+ import threading
54
+
55
+ thread_id = threading.get_ident()
56
+
57
+ with _correlation_lock:
58
+ _correlation_ids.pop(thread_id, None)
59
+
60
+
61
+ class StructuredLogger:
62
+ """
63
+ Structured JSON logger with correlation ID support.
64
+
65
+ Provides consistent structured logging across the application.
66
+ """
67
+
68
+ def __init__(self, name: str):
69
+ self.logger = logging.getLogger(name)
70
+ self.name = name
71
+
72
+ def _log_structured(
73
+ self,
74
+ level: int,
75
+ message: str,
76
+ extra: dict[str, Any] | None = None,
77
+ exc_info: Exception | None = None,
78
+ ) -> None:
79
+ """Log a structured message with correlation ID and metadata."""
80
+ log_data = {
81
+ "message": message,
82
+ "correlation_id": get_correlation_id(),
83
+ "logger": self.name,
84
+ "timestamp": time.time(),
85
+ }
86
+
87
+ if extra:
88
+ # Mask sensitive data before logging
89
+ log_data["extra"] = mask_sensitive_data(extra)
90
+
91
+ # Use standard logger with JSON-formatted message
92
+ self.logger.log(
93
+ level,
94
+ json.dumps(log_data),
95
+ exc_info=exc_info,
96
+ )
97
+
98
+ def debug(self, message: str, **kwargs) -> None:
99
+ """Log a debug message."""
100
+ self._log_structured(logging.DEBUG, message, extra=kwargs)
101
+
102
+ def info(self, message: str, **kwargs) -> None:
103
+ """Log an info message."""
104
+ self._log_structured(logging.INFO, message, extra=kwargs)
105
+
106
+ def warning(self, message: str, **kwargs) -> None:
107
+ """Log a warning message."""
108
+ self._log_structured(logging.WARNING, message, extra=kwargs)
109
+
110
+ def error(self, message: str, exc_info: Exception | None = None, **kwargs) -> None:
111
+ """Log an error message."""
112
+ self._log_structured(logging.ERROR, message, extra=kwargs, exc_info=exc_info)
113
+
114
+ def critical(self, message: str, exc_info: Exception | None = None, **kwargs) -> None:
115
+ """Log a critical message."""
116
+ self._log_structured(logging.CRITICAL, message, extra=kwargs, exc_info=exc_info)
117
+
118
+
119
+ class MetricsCollector:
120
+ """
121
+ Simple metrics collector for tracking request statistics.
122
+
123
+ Tracks:
124
+ - Request counts by endpoint and status
125
+ - Response latencies (p50, p95, p99)
126
+ - Error rates
127
+ - Active connections
128
+ """
129
+
130
+ def __init__(self):
131
+ self._lock = Lock()
132
+ self._request_counts: dict[str, int] = defaultdict(int)
133
+ self._error_counts: dict[str, int] = defaultdict(int)
134
+ self._latencies: dict[str, list[float]] = defaultdict(list)
135
+ self._active_requests = 0
136
+ self._max_latency_samples = 1000 # Keep last 1000 samples per endpoint
137
+
138
+ def increment_requests(self, endpoint: str, status_code: int) -> None:
139
+ """Increment request counter for an endpoint."""
140
+ with self._lock:
141
+ key = f"{endpoint}:{status_code}"
142
+ self._request_counts[key] += 1
143
+
144
+ if status_code >= 400:
145
+ self._error_counts[endpoint] += 1
146
+
147
+ def record_latency(self, endpoint: str, latency_ms: float) -> None:
148
+ """Record request latency for an endpoint."""
149
+ with self._lock:
150
+ self._latencies[endpoint].append(latency_ms)
151
+
152
+ # Keep only recent samples
153
+ if len(self._latencies[endpoint]) > self._max_latency_samples:
154
+ self._latencies[endpoint] = self._latencies[endpoint][-self._max_latency_samples :]
155
+
156
+ def increment_active_requests(self) -> None:
157
+ """Increment active request counter."""
158
+ with self._lock:
159
+ self._active_requests += 1
160
+
161
+ def decrement_active_requests(self) -> None:
162
+ """Decrement active request counter."""
163
+ with self._lock:
164
+ self._active_requests = max(0, self._active_requests - 1)
165
+
166
+ def get_metrics(self) -> dict[str, Any]:
167
+ """Get current metrics snapshot."""
168
+ with self._lock:
169
+ # Calculate latency percentiles
170
+ latency_stats = {}
171
+ for endpoint, latencies in self._latencies.items():
172
+ if latencies:
173
+ sorted_latencies = sorted(latencies)
174
+ latency_stats[endpoint] = {
175
+ "p50": self._percentile(sorted_latencies, 50),
176
+ "p95": self._percentile(sorted_latencies, 95),
177
+ "p99": self._percentile(sorted_latencies, 99),
178
+ "count": len(latencies),
179
+ }
180
+
181
+ return {
182
+ "request_counts": dict(self._request_counts),
183
+ "error_counts": dict(self._error_counts),
184
+ "latency_stats": latency_stats,
185
+ "active_requests": self._active_requests,
186
+ }
187
+
188
+ def _percentile(self, sorted_values: list[float], percentile: int) -> float:
189
+ """Calculate percentile from sorted values."""
190
+ if not sorted_values:
191
+ return 0.0
192
+
193
+ index = int(len(sorted_values) * percentile / 100)
194
+ index = min(index, len(sorted_values) - 1)
195
+ return sorted_values[index]
196
+
197
+ def reset(self) -> None:
198
+ """Reset all metrics."""
199
+ with self._lock:
200
+ self._request_counts.clear()
201
+ self._error_counts.clear()
202
+ self._latencies.clear()
203
+ self._active_requests = 0
204
+
205
+
206
+ # Global metrics collector
207
+ _metrics_collector = MetricsCollector()
208
+
209
+
210
+ def get_metrics_collector() -> MetricsCollector:
211
+ """Get the global metrics collector instance."""
212
+ return _metrics_collector
213
+
214
+
215
+ @contextmanager
216
+ def track_request(endpoint: str):
217
+ """
218
+ Context manager to track request metrics.
219
+
220
+ Usage:
221
+ with track_request("/api/incidents"):
222
+ # ... make request ...
223
+ pass
224
+ """
225
+ collector = get_metrics_collector()
226
+ collector.increment_active_requests()
227
+ start_time = time.time()
228
+ status_code = 200 # Default
229
+
230
+ try:
231
+ yield
232
+ except Exception:
233
+ status_code = 500
234
+ raise
235
+ finally:
236
+ latency_ms = (time.time() - start_time) * 1000
237
+ collector.record_latency(endpoint, latency_ms)
238
+ collector.increment_requests(endpoint, status_code)
239
+ collector.decrement_active_requests()
240
+
241
+
242
+ def log_request(logger: StructuredLogger):
243
+ """
244
+ Decorator to log requests and responses.
245
+
246
+ Args:
247
+ logger: StructuredLogger instance to use
248
+ """
249
+
250
+ def decorator(func: Callable) -> Callable:
251
+ @wraps(func)
252
+ async def async_wrapper(*args, **kwargs):
253
+ # Generate new correlation ID for this request
254
+ set_correlation_id(str(uuid.uuid4()))
255
+
256
+ # Log request
257
+ logger.info(
258
+ f"Request started: {func.__name__}",
259
+ function=func.__name__,
260
+ args_count=len(args),
261
+ kwargs_keys=list(kwargs.keys()),
262
+ )
263
+
264
+ start_time = time.time()
265
+ try:
266
+ result = await func(*args, **kwargs)
267
+
268
+ # Log success
269
+ duration_ms = (time.time() - start_time) * 1000
270
+ logger.info(
271
+ f"Request completed: {func.__name__}",
272
+ function=func.__name__,
273
+ duration_ms=duration_ms,
274
+ status="success",
275
+ )
276
+
277
+ return result
278
+
279
+ except Exception as e:
280
+ # Log error
281
+ duration_ms = (time.time() - start_time) * 1000
282
+ logger.error(
283
+ f"Request failed: {func.__name__}",
284
+ exc_info=e,
285
+ function=func.__name__,
286
+ duration_ms=duration_ms,
287
+ status="error",
288
+ error_type=type(e).__name__,
289
+ )
290
+ raise
291
+
292
+ finally:
293
+ clear_correlation_id()
294
+
295
+ @wraps(func)
296
+ def sync_wrapper(*args, **kwargs):
297
+ # Generate new correlation ID for this request
298
+ set_correlation_id(str(uuid.uuid4()))
299
+
300
+ # Log request
301
+ logger.info(
302
+ f"Request started: {func.__name__}",
303
+ function=func.__name__,
304
+ args_count=len(args),
305
+ kwargs_keys=list(kwargs.keys()),
306
+ )
307
+
308
+ start_time = time.time()
309
+ try:
310
+ result = func(*args, **kwargs)
311
+
312
+ # Log success
313
+ duration_ms = (time.time() - start_time) * 1000
314
+ logger.info(
315
+ f"Request completed: {func.__name__}",
316
+ function=func.__name__,
317
+ duration_ms=duration_ms,
318
+ status="success",
319
+ )
320
+
321
+ return result
322
+
323
+ except Exception as e:
324
+ # Log error
325
+ duration_ms = (time.time() - start_time) * 1000
326
+ logger.error(
327
+ f"Request failed: {func.__name__}",
328
+ exc_info=e,
329
+ function=func.__name__,
330
+ duration_ms=duration_ms,
331
+ status="error",
332
+ error_type=type(e).__name__,
333
+ )
334
+ raise
335
+
336
+ finally:
337
+ clear_correlation_id()
338
+
339
+ # Return appropriate wrapper based on function type
340
+ import asyncio
341
+
342
+ if asyncio.iscoroutinefunction(func):
343
+ return async_wrapper
344
+ return sync_wrapper
345
+
346
+ return decorator
347
+
348
+
349
+ def get_health_status() -> dict[str, Any]:
350
+ """
351
+ Get health check status of the server.
352
+
353
+ Returns:
354
+ Dictionary with health status information
355
+ """
356
+ metrics = get_metrics_collector().get_metrics()
357
+
358
+ # Calculate overall health based on error rate
359
+ total_requests = sum(metrics["request_counts"].values())
360
+ total_errors = sum(metrics["error_counts"].values())
361
+
362
+ error_rate = (total_errors / total_requests * 100) if total_requests > 0 else 0
363
+
364
+ # Determine status based on error rate
365
+ if error_rate > 50:
366
+ status = "unhealthy"
367
+ elif error_rate > 10:
368
+ status = "degraded"
369
+ else:
370
+ status = "healthy"
371
+
372
+ return {
373
+ "status": status,
374
+ "error_rate_percent": round(error_rate, 2),
375
+ "active_requests": metrics["active_requests"],
376
+ "total_requests": total_requests,
377
+ "total_errors": total_errors,
378
+ }
@@ -0,0 +1,98 @@
1
+ """
2
+ Pagination utilities for the Rootly MCP Server.
3
+
4
+ This module provides helpers for paginated API requests.
5
+ """
6
+
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+
11
+ async def fetch_all_pages(
12
+ fetch_func: Callable,
13
+ max_results: int,
14
+ page_size: int = 10,
15
+ **kwargs,
16
+ ) -> dict[str, Any]:
17
+ """
18
+ Fetch all pages from a paginated endpoint up to max_results.
19
+
20
+ Args:
21
+ fetch_func: Async function to fetch a single page
22
+ max_results: Maximum total results to fetch
23
+ page_size: Number of items per page
24
+ **kwargs: Additional arguments to pass to fetch_func
25
+
26
+ Returns:
27
+ Combined results from all pages
28
+ """
29
+ all_results = []
30
+ page = 1
31
+ total_fetched = 0
32
+
33
+ while total_fetched < max_results:
34
+ # Fetch one page
35
+ response = await fetch_func(page_size=page_size, page_number=page, **kwargs)
36
+
37
+ # Extract data from response
38
+ if isinstance(response, dict):
39
+ data = response.get("data", [])
40
+ else:
41
+ data = []
42
+
43
+ if not data:
44
+ break
45
+
46
+ # Add results
47
+ remaining = max_results - total_fetched
48
+ all_results.extend(data[:remaining])
49
+ total_fetched += len(data[:remaining])
50
+
51
+ # Check if we got fewer results than page size (last page)
52
+ if len(data) < page_size:
53
+ break
54
+
55
+ page += 1
56
+
57
+ return {"data": all_results, "total_fetched": total_fetched}
58
+
59
+
60
+ def build_pagination_params(
61
+ page_size: int = 10,
62
+ page_number: int = 1,
63
+ ) -> dict[str, Any]:
64
+ """
65
+ Build pagination parameters for Rootly API.
66
+
67
+ Args:
68
+ page_size: Number of items per page
69
+ page_number: Page number (1-indexed)
70
+
71
+ Returns:
72
+ Dictionary of pagination parameters
73
+ """
74
+ return {
75
+ "page[size]": page_size,
76
+ "page[number]": page_number,
77
+ }
78
+
79
+
80
+ def extract_pagination_meta(response: dict[str, Any]) -> dict[str, Any]:
81
+ """
82
+ Extract pagination metadata from API response.
83
+
84
+ Args:
85
+ response: API response dictionary
86
+
87
+ Returns:
88
+ Pagination metadata
89
+ """
90
+ meta = response.get("meta", {})
91
+ pagination = meta.get("pagination", {})
92
+
93
+ return {
94
+ "current_page": pagination.get("current_page", 1),
95
+ "total_pages": pagination.get("total_pages", 1),
96
+ "total_count": pagination.get("total_count", 0),
97
+ "per_page": pagination.get("per_page", 10),
98
+ }