levelapp 0.1.15__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.
- levelapp/__init__.py +0 -0
- levelapp/aspects/__init__.py +8 -0
- levelapp/aspects/loader.py +253 -0
- levelapp/aspects/logger.py +59 -0
- levelapp/aspects/monitor.py +617 -0
- levelapp/aspects/sanitizer.py +168 -0
- levelapp/clients/__init__.py +122 -0
- levelapp/clients/anthropic.py +112 -0
- levelapp/clients/gemini.py +130 -0
- levelapp/clients/groq.py +101 -0
- levelapp/clients/huggingface.py +162 -0
- levelapp/clients/ionos.py +126 -0
- levelapp/clients/mistral.py +106 -0
- levelapp/clients/openai.py +116 -0
- levelapp/comparator/__init__.py +5 -0
- levelapp/comparator/comparator.py +232 -0
- levelapp/comparator/extractor.py +108 -0
- levelapp/comparator/schemas.py +61 -0
- levelapp/comparator/scorer.py +269 -0
- levelapp/comparator/utils.py +136 -0
- levelapp/config/__init__.py +5 -0
- levelapp/config/endpoint.py +199 -0
- levelapp/config/prompts.py +57 -0
- levelapp/core/__init__.py +0 -0
- levelapp/core/base.py +386 -0
- levelapp/core/schemas.py +24 -0
- levelapp/core/session.py +336 -0
- levelapp/endpoint/__init__.py +0 -0
- levelapp/endpoint/client.py +188 -0
- levelapp/endpoint/client_test.py +41 -0
- levelapp/endpoint/manager.py +114 -0
- levelapp/endpoint/parsers.py +119 -0
- levelapp/endpoint/schemas.py +38 -0
- levelapp/endpoint/tester.py +52 -0
- levelapp/evaluator/__init__.py +3 -0
- levelapp/evaluator/evaluator.py +307 -0
- levelapp/metrics/__init__.py +63 -0
- levelapp/metrics/embedding.py +56 -0
- levelapp/metrics/embeddings/__init__.py +0 -0
- levelapp/metrics/embeddings/sentence_transformer.py +30 -0
- levelapp/metrics/embeddings/torch_based.py +56 -0
- levelapp/metrics/exact.py +182 -0
- levelapp/metrics/fuzzy.py +80 -0
- levelapp/metrics/token.py +103 -0
- levelapp/plugins/__init__.py +0 -0
- levelapp/repository/__init__.py +3 -0
- levelapp/repository/filesystem.py +203 -0
- levelapp/repository/firestore.py +291 -0
- levelapp/simulator/__init__.py +3 -0
- levelapp/simulator/schemas.py +116 -0
- levelapp/simulator/simulator.py +531 -0
- levelapp/simulator/utils.py +134 -0
- levelapp/visualization/__init__.py +7 -0
- levelapp/visualization/charts.py +358 -0
- levelapp/visualization/dashboard.py +240 -0
- levelapp/visualization/exporter.py +167 -0
- levelapp/visualization/templates/base.html +158 -0
- levelapp/visualization/templates/comparator_dashboard.html +57 -0
- levelapp/visualization/templates/simulator_dashboard.html +111 -0
- levelapp/workflow/__init__.py +6 -0
- levelapp/workflow/base.py +192 -0
- levelapp/workflow/config.py +96 -0
- levelapp/workflow/context.py +64 -0
- levelapp/workflow/factory.py +42 -0
- levelapp/workflow/registration.py +6 -0
- levelapp/workflow/runtime.py +19 -0
- levelapp-0.1.15.dist-info/METADATA +571 -0
- levelapp-0.1.15.dist-info/RECORD +70 -0
- levelapp-0.1.15.dist-info/WHEEL +4 -0
- levelapp-0.1.15.dist-info/licenses/LICENSE +0 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""levelapp/aspects.monitor.py"""
|
|
2
|
+
import threading
|
|
3
|
+
import tracemalloc
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from dataclasses import dataclass, fields
|
|
9
|
+
from typing import List, Dict, Callable, Any, Union, ParamSpec, TypeVar, runtime_checkable, Protocol, Type
|
|
10
|
+
|
|
11
|
+
from threading import RLock
|
|
12
|
+
from functools import wraps
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
from humanize import precisedelta, naturalsize
|
|
15
|
+
|
|
16
|
+
from levelapp.aspects import logger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
P = ParamSpec('P')
|
|
20
|
+
T = TypeVar('T')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MetricType(Enum):
|
|
24
|
+
"""Types of metrics that can be collected."""
|
|
25
|
+
SETUP = "setup"
|
|
26
|
+
DATA_LOADING = "data_loading"
|
|
27
|
+
EXECUTION = "execution"
|
|
28
|
+
RESULTS_COLLECTION = "results_collection"
|
|
29
|
+
|
|
30
|
+
API_CALL = "api_call"
|
|
31
|
+
SCORING = "scoring"
|
|
32
|
+
CUSTOM = "custom"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ExecutionMetrics:
|
|
37
|
+
"""Comprehensive metrics for a function execution."""
|
|
38
|
+
procedure: str
|
|
39
|
+
category: MetricType = MetricType.CUSTOM
|
|
40
|
+
start_time: datetime | None = None
|
|
41
|
+
end_time: datetime | None = None
|
|
42
|
+
duration: float | None = None
|
|
43
|
+
total_api_calls: int = 0
|
|
44
|
+
memory_before: int | None = None
|
|
45
|
+
memory_after: int | None = None
|
|
46
|
+
memory_peak: int | None = None
|
|
47
|
+
cache_hit: bool = False
|
|
48
|
+
error: str | None = None
|
|
49
|
+
|
|
50
|
+
def finalize(self) -> None:
|
|
51
|
+
"""Finalize metrics calculation."""
|
|
52
|
+
if self.end_time and self.start_time:
|
|
53
|
+
self.duration = (self.end_time - self.start_time).total_seconds()
|
|
54
|
+
|
|
55
|
+
def update_duration(self, value: float) -> None:
|
|
56
|
+
"""Update duration with explicit value."""
|
|
57
|
+
if value < 0:
|
|
58
|
+
raise ValueError("Duration value cannot be negative.")
|
|
59
|
+
self.duration = value
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict:
|
|
62
|
+
"""Returns the content of the ExecutionMetrics as a structured dictionary."""
|
|
63
|
+
metrics_dict = {}
|
|
64
|
+
for field_info in fields(self):
|
|
65
|
+
value = getattr(self, field_info.name)
|
|
66
|
+
|
|
67
|
+
# Special handling for enum types to convert them to their value
|
|
68
|
+
if isinstance(value, Enum):
|
|
69
|
+
metrics_dict[field_info.name] = value.name
|
|
70
|
+
elif isinstance(value, datetime):
|
|
71
|
+
metrics_dict[field_info.name] = value.isoformat()
|
|
72
|
+
else:
|
|
73
|
+
metrics_dict[field_info.name] = value
|
|
74
|
+
|
|
75
|
+
return metrics_dict
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class AggregatedStats:
|
|
80
|
+
"""Aggregated metrics for monitored functions."""
|
|
81
|
+
total_calls: int = 0
|
|
82
|
+
total_duration: float = 0.0
|
|
83
|
+
min_duration: float = float('inf')
|
|
84
|
+
max_duration: float = 0.0
|
|
85
|
+
error_count: int = 0
|
|
86
|
+
cache_hits: int = 0
|
|
87
|
+
memory_peak: int = 0
|
|
88
|
+
recent_call: datetime | None = None
|
|
89
|
+
|
|
90
|
+
def update(self, metrics: ExecutionMetrics) -> None:
|
|
91
|
+
"""Update aggregated metrics with new execution metrics."""
|
|
92
|
+
self.recent_call = datetime.now()
|
|
93
|
+
self.total_calls += 1
|
|
94
|
+
|
|
95
|
+
if metrics.duration is not None:
|
|
96
|
+
self.total_duration += metrics.duration
|
|
97
|
+
|
|
98
|
+
if self.min_duration == float('inf'):
|
|
99
|
+
self.min_duration = metrics.duration
|
|
100
|
+
else:
|
|
101
|
+
self.min_duration = min(self.min_duration, metrics.duration)
|
|
102
|
+
|
|
103
|
+
self.max_duration = max(self.max_duration, metrics.duration)
|
|
104
|
+
|
|
105
|
+
if metrics.error:
|
|
106
|
+
self.error_count += 1
|
|
107
|
+
|
|
108
|
+
if metrics.cache_hit:
|
|
109
|
+
self.cache_hits += 1
|
|
110
|
+
|
|
111
|
+
if metrics.memory_peak:
|
|
112
|
+
self.memory_peak = max(self.memory_peak, metrics.memory_peak)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def average_duration(self) -> float:
|
|
116
|
+
"""Average execution duration."""
|
|
117
|
+
return (self.total_duration / self.total_calls) if self.total_calls > 0 else 0.0
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def cache_hit_rate(self) -> float:
|
|
121
|
+
"""Cache hit rate as a percentage."""
|
|
122
|
+
return (self.cache_hits / self.total_calls * 100) if self.total_calls > 0 else 0.0
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def error_rate(self) -> float:
|
|
126
|
+
"""Error rate as a percentage."""
|
|
127
|
+
return (self.error_count / self.total_calls * 100) if self.total_calls > 0 else 0.0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@runtime_checkable
|
|
131
|
+
class MetricsCollector(Protocol):
|
|
132
|
+
"""Protocol for custom metrics collectors."""
|
|
133
|
+
|
|
134
|
+
def collect_before(self, collected_metrics: ExecutionMetrics) -> ExecutionMetrics:
|
|
135
|
+
"""Collect metrics before function execution."""
|
|
136
|
+
...
|
|
137
|
+
|
|
138
|
+
def collect_after(self, collected_metrics: ExecutionMetrics) -> ExecutionMetrics:
|
|
139
|
+
"""Collect metrics after function execution."""
|
|
140
|
+
...
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class MemoryTracker(MetricsCollector):
|
|
144
|
+
"""Memory usage metrics collector."""
|
|
145
|
+
def __init__(self):
|
|
146
|
+
self._tracking = False
|
|
147
|
+
self._lock = threading.Lock()
|
|
148
|
+
|
|
149
|
+
@contextmanager
|
|
150
|
+
def _ensure_tracking(self):
|
|
151
|
+
"""Context manager to ensure tracemalloc is properly managed."""
|
|
152
|
+
with self._lock:
|
|
153
|
+
if not self._tracking:
|
|
154
|
+
tracemalloc.start()
|
|
155
|
+
self._tracking = True
|
|
156
|
+
try:
|
|
157
|
+
yield
|
|
158
|
+
finally:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
def collect_before(self, collected_metrics: ExecutionMetrics) -> ExecutionMetrics:
|
|
162
|
+
with self._ensure_tracking():
|
|
163
|
+
try:
|
|
164
|
+
current, _ = tracemalloc.get_traced_memory()
|
|
165
|
+
collected_metrics.memory_before = current
|
|
166
|
+
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.warning(f"[MemoryTracker] Memory tracking failed: {e}")
|
|
169
|
+
|
|
170
|
+
return collected_metrics
|
|
171
|
+
|
|
172
|
+
def collect_after(self, collected_metrics: ExecutionMetrics) -> ExecutionMetrics:
|
|
173
|
+
if self._tracking:
|
|
174
|
+
try:
|
|
175
|
+
current, peak = tracemalloc.get_traced_memory()
|
|
176
|
+
collected_metrics.memory_after = current
|
|
177
|
+
collected_metrics.memory_peak = peak
|
|
178
|
+
return collected_metrics
|
|
179
|
+
|
|
180
|
+
except Exception as e:
|
|
181
|
+
logger.warning(f"Memory tracking failed: {e}")
|
|
182
|
+
return collected_metrics
|
|
183
|
+
|
|
184
|
+
def cleanup(self):
|
|
185
|
+
"""Explicit cleanup method."""
|
|
186
|
+
with self._lock:
|
|
187
|
+
if self._tracking:
|
|
188
|
+
try:
|
|
189
|
+
tracemalloc.stop()
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.warning(f"Failed to stop tracemalloc: {e}")
|
|
192
|
+
finally:
|
|
193
|
+
self._tracking = False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class APICallTracker(MetricsCollector):
|
|
197
|
+
"""API call metrics collector for LLM clients."""
|
|
198
|
+
|
|
199
|
+
def __init__(self):
|
|
200
|
+
self._api_calls = defaultdict(int)
|
|
201
|
+
self._lock = threading.Lock()
|
|
202
|
+
|
|
203
|
+
def collect_before(self, collected_metrics: ExecutionMetrics) -> ExecutionMetrics:
|
|
204
|
+
return collected_metrics
|
|
205
|
+
|
|
206
|
+
def collect_after(self, collected_metrics: ExecutionMetrics) -> ExecutionMetrics:
|
|
207
|
+
with self._lock:
|
|
208
|
+
if collected_metrics.category == MetricType.API_CALL:
|
|
209
|
+
self._api_calls[collected_metrics.procedure] += 1
|
|
210
|
+
collected_metrics.total_api_calls = self._api_calls[collected_metrics.procedure]
|
|
211
|
+
|
|
212
|
+
return collected_metrics
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class FunctionMonitor:
|
|
216
|
+
"""Core function monitoring system."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, max_history: int = 1000):
|
|
219
|
+
self._lock = RLock()
|
|
220
|
+
self._max_history = max_history
|
|
221
|
+
self._monitored_procedures: Dict[str, Callable[..., Any]] = {}
|
|
222
|
+
self._execution_history: Dict[str, List[ExecutionMetrics]] = defaultdict(list)
|
|
223
|
+
self._aggregated_stats: Dict[str, AggregatedStats] = defaultdict(AggregatedStats)
|
|
224
|
+
|
|
225
|
+
self._collectors: List[MetricsCollector] = []
|
|
226
|
+
|
|
227
|
+
self.add_collector(MemoryTracker())
|
|
228
|
+
self.add_collector(APICallTracker())
|
|
229
|
+
|
|
230
|
+
def update_procedure_duration(self, name: str, value: float) -> None:
|
|
231
|
+
"""
|
|
232
|
+
Update the duration of a monitored procedure by name.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
name: The name of the procedure to retrieve.
|
|
236
|
+
value: The value to retrieve for the procedure.
|
|
237
|
+
"""
|
|
238
|
+
with self._lock:
|
|
239
|
+
history = self._execution_history.get(name, [])
|
|
240
|
+
if not history:
|
|
241
|
+
logger.warning(f"[FunctionMonitor] ne execution history found for proc: {name}")
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
latest_entry = history[-1]
|
|
245
|
+
latest_entry.update_duration(value=value)
|
|
246
|
+
self._aggregated_stats[name] = AggregatedStats()
|
|
247
|
+
|
|
248
|
+
for entry in history:
|
|
249
|
+
self._aggregated_stats[name].update(metrics=entry)
|
|
250
|
+
|
|
251
|
+
def add_collector(self, collector: MetricsCollector) -> None:
|
|
252
|
+
"""
|
|
253
|
+
Add a custom metrics collector to the monitor.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
collector: An instance of a class implementing MetricsCollector protocol.
|
|
257
|
+
"""
|
|
258
|
+
with self._lock:
|
|
259
|
+
self._collectors.append(collector)
|
|
260
|
+
|
|
261
|
+
def remove_collector(self, collector: MetricsCollector) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Remove a custom metrics collector from the monitor.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
collector: An instance of a class implementing MetricsCollector protocol.
|
|
267
|
+
"""
|
|
268
|
+
with self._lock:
|
|
269
|
+
if collector in self._collectors:
|
|
270
|
+
self._collectors.remove(collector)
|
|
271
|
+
|
|
272
|
+
def _collect_metrics_before(self, execution_metrics: ExecutionMetrics) -> ExecutionMetrics:
|
|
273
|
+
"""Collect metrics before function execution using registered collectors."""
|
|
274
|
+
result_metrics = execution_metrics
|
|
275
|
+
for collector in self._collectors:
|
|
276
|
+
try:
|
|
277
|
+
result_metrics = collector.collect_before(result_metrics)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
logger.warning(f"Metrics collector {type(collector).__name__} failed: {e}")
|
|
280
|
+
|
|
281
|
+
return result_metrics
|
|
282
|
+
|
|
283
|
+
def _collect_metrics_after(self, execution_metrics: ExecutionMetrics) -> ExecutionMetrics:
|
|
284
|
+
"""Collect metrics after function execution using registered collectors."""
|
|
285
|
+
result_metrics = execution_metrics
|
|
286
|
+
for collector in self._collectors:
|
|
287
|
+
try:
|
|
288
|
+
result_metrics = collector.collect_after(result_metrics)
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.warning(f"Metrics collector {type(collector).__name__} failed: {e}")
|
|
291
|
+
return result_metrics
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _apply_caching(func: Callable[P, T], maxsize: int | None) -> Callable[P, T]:
|
|
295
|
+
if maxsize is None:
|
|
296
|
+
return func
|
|
297
|
+
|
|
298
|
+
def make_args_hashable(args, kwargs):
|
|
299
|
+
hashable_args = tuple(_make_hashable(a) for a in args)
|
|
300
|
+
hashable_kwargs = tuple(sorted((k, _make_hashable(v)) for k, v in kwargs.items()))
|
|
301
|
+
return hashable_args, hashable_kwargs
|
|
302
|
+
|
|
303
|
+
cache = {}
|
|
304
|
+
cache_info = {"hits": 0, "misses": 0}
|
|
305
|
+
|
|
306
|
+
@wraps(func)
|
|
307
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
308
|
+
nonlocal cache_info
|
|
309
|
+
cache_key = make_args_hashable(args, kwargs)
|
|
310
|
+
|
|
311
|
+
if cache_key in cache:
|
|
312
|
+
cache_info["hits"] += 1
|
|
313
|
+
return cache[cache_key]
|
|
314
|
+
|
|
315
|
+
cache_info["misses"] += 1
|
|
316
|
+
result = func(*args, **kwargs)
|
|
317
|
+
cache[cache_key] = result
|
|
318
|
+
|
|
319
|
+
# Enforce maxsize
|
|
320
|
+
if len(cache) > maxsize:
|
|
321
|
+
cache.pop(next(iter(cache)))
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
# Add cache management methods
|
|
325
|
+
def get_cache_info():
|
|
326
|
+
return dict(cache_info)
|
|
327
|
+
|
|
328
|
+
def clear_cache():
|
|
329
|
+
nonlocal cache, cache_info
|
|
330
|
+
cache.clear()
|
|
331
|
+
cache_info = {"hits": 0, "misses": 0}
|
|
332
|
+
|
|
333
|
+
wrapper.cache_info = get_cache_info
|
|
334
|
+
wrapper.cache_clear = clear_cache
|
|
335
|
+
wrapper._cache = cache # For debugging/inspection
|
|
336
|
+
|
|
337
|
+
return wrapper
|
|
338
|
+
|
|
339
|
+
def _wrap_execution(
|
|
340
|
+
self,
|
|
341
|
+
func: Callable[P, T],
|
|
342
|
+
name: str,
|
|
343
|
+
category: MetricType,
|
|
344
|
+
enable_timing: bool,
|
|
345
|
+
track_memory: bool,
|
|
346
|
+
verbose=False
|
|
347
|
+
) -> Callable[P, T]:
|
|
348
|
+
"""
|
|
349
|
+
Wrap function execution with timing and error handling.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
func: Function to be wrapped
|
|
353
|
+
name: Unique identifier for the function
|
|
354
|
+
enable_timing: Enable execution time logging
|
|
355
|
+
track_memory: Enable memory tracking
|
|
356
|
+
verbose: Enable verbose logging
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Wrapped function
|
|
360
|
+
"""
|
|
361
|
+
@wraps(func)
|
|
362
|
+
def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
363
|
+
metrics = ExecutionMetrics(
|
|
364
|
+
procedure=name,
|
|
365
|
+
category=category,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if enable_timing:
|
|
369
|
+
metrics.start_time = datetime.now()
|
|
370
|
+
|
|
371
|
+
# Collect pre-execution metrics
|
|
372
|
+
if track_memory:
|
|
373
|
+
self._collect_metrics_before(execution_metrics=metrics)
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
result = func(*args, **kwargs)
|
|
377
|
+
|
|
378
|
+
cache_hit_info = getattr(func, 'cache_hit_info', None)
|
|
379
|
+
if hasattr(func, 'cache_info') and cache_hit_info is not None:
|
|
380
|
+
metrics.cache_hit = getattr(cache_hit_info, 'is_hit', False)
|
|
381
|
+
|
|
382
|
+
return result
|
|
383
|
+
|
|
384
|
+
except Exception as e:
|
|
385
|
+
metrics.error = str(e)
|
|
386
|
+
logger.error(f"Error in '{name}': {str(e)}", exc_info=True)
|
|
387
|
+
raise
|
|
388
|
+
|
|
389
|
+
finally:
|
|
390
|
+
if enable_timing:
|
|
391
|
+
metrics.end_time = datetime.now()
|
|
392
|
+
metrics.finalize()
|
|
393
|
+
|
|
394
|
+
if track_memory:
|
|
395
|
+
metrics = self._collect_metrics_after(execution_metrics=metrics)
|
|
396
|
+
|
|
397
|
+
# store metrics
|
|
398
|
+
with self._lock:
|
|
399
|
+
history = self._execution_history[name]
|
|
400
|
+
history.append(metrics)
|
|
401
|
+
|
|
402
|
+
if len(history) > self._max_history:
|
|
403
|
+
history.pop(0)
|
|
404
|
+
|
|
405
|
+
self._aggregated_stats[name].update(metrics=metrics)
|
|
406
|
+
|
|
407
|
+
if verbose and enable_timing and metrics.duration is not None:
|
|
408
|
+
log_message = f"[FunctionMonitor] Executed '{name}' in {metrics.duration:.4f}s"
|
|
409
|
+
if metrics.cache_hit:
|
|
410
|
+
log_message += " (cache hit)"
|
|
411
|
+
if metrics.memory_peak:
|
|
412
|
+
log_message += f" (memory peak: {metrics.memory_peak / 1024 / 1024:.2f} MB)"
|
|
413
|
+
logger.info(log_message)
|
|
414
|
+
|
|
415
|
+
return wrapped
|
|
416
|
+
|
|
417
|
+
def monitor(
|
|
418
|
+
self,
|
|
419
|
+
name: str,
|
|
420
|
+
category: MetricType = MetricType.CUSTOM,
|
|
421
|
+
cached: bool = False,
|
|
422
|
+
maxsize: int | None = 128,
|
|
423
|
+
enable_timing: bool = True,
|
|
424
|
+
track_memory: bool = True,
|
|
425
|
+
collectors: List[Type[MetricsCollector]] | None = None,
|
|
426
|
+
verbose: bool = False
|
|
427
|
+
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
|
428
|
+
"""
|
|
429
|
+
Decorator factory for monitoring functions.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
name: Unique identifier for the function
|
|
433
|
+
category: Category of the metric (e.g., API_CALL, SCORING)
|
|
434
|
+
cached: Enable LRU caching
|
|
435
|
+
maxsize: Maximum cache size
|
|
436
|
+
enable_timing: Record execution time
|
|
437
|
+
track_memory: Track memory usage
|
|
438
|
+
collectors: Optional list of custom metrics collectors
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Callable[[Callable[P, T]], Callable[P, T]]: Decorator function
|
|
442
|
+
"""
|
|
443
|
+
def decorator(func: Callable[P, T]) -> Callable[P, T]:
|
|
444
|
+
if collectors:
|
|
445
|
+
for collector in collectors:
|
|
446
|
+
self.add_collector(collector)
|
|
447
|
+
|
|
448
|
+
if cached:
|
|
449
|
+
func = self._apply_caching(func=func, maxsize=maxsize)
|
|
450
|
+
|
|
451
|
+
monitored_func = self._wrap_execution(
|
|
452
|
+
func=func,
|
|
453
|
+
name=name,
|
|
454
|
+
category=category,
|
|
455
|
+
enable_timing=enable_timing,
|
|
456
|
+
track_memory=track_memory,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
with self._lock:
|
|
460
|
+
if name in self._monitored_procedures and verbose:
|
|
461
|
+
raise ValueError(f"Function '{name}' is already registered.")
|
|
462
|
+
|
|
463
|
+
self._monitored_procedures[name] = monitored_func
|
|
464
|
+
|
|
465
|
+
return monitored_func
|
|
466
|
+
|
|
467
|
+
return decorator
|
|
468
|
+
|
|
469
|
+
def list_monitored_functions(self) -> Dict[str, Callable[..., Any]]:
|
|
470
|
+
"""
|
|
471
|
+
List all registered monitored functions.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
List[str]: Names of all registered functions
|
|
475
|
+
"""
|
|
476
|
+
with self._lock:
|
|
477
|
+
return dict(self._monitored_procedures)
|
|
478
|
+
|
|
479
|
+
def get_stats(self, name: str) -> Dict[str, Any] | None:
|
|
480
|
+
"""
|
|
481
|
+
Get comprehensive statistics for a monitored function.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
name (str): Name of the monitored function.
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
Dict[str, Any] | None: Dictionary containing function statistics or None if not found.
|
|
488
|
+
"""
|
|
489
|
+
with self._lock:
|
|
490
|
+
if name not in self._monitored_procedures:
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
func = self._monitored_procedures[name]
|
|
494
|
+
stats = self._aggregated_stats[name]
|
|
495
|
+
|
|
496
|
+
min_duration = stats.min_duration if stats.min_duration != float('inf') else 0.0
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
'name': name,
|
|
500
|
+
'total_calls': stats.total_calls,
|
|
501
|
+
'avg_duration': precisedelta(
|
|
502
|
+
timedelta(seconds=stats.average_duration),
|
|
503
|
+
suppress=['minutes'],
|
|
504
|
+
format='%.4f'
|
|
505
|
+
) if stats.average_duration > 0 else "0.000s",
|
|
506
|
+
'min_duration': precisedelta(
|
|
507
|
+
timedelta(seconds=min_duration),
|
|
508
|
+
suppress=['minutes'],
|
|
509
|
+
format='%.4f'
|
|
510
|
+
),
|
|
511
|
+
'max_duration': precisedelta(
|
|
512
|
+
timedelta(seconds=stats.max_duration),
|
|
513
|
+
suppress=['minutes'],
|
|
514
|
+
format='%.4f'
|
|
515
|
+
),
|
|
516
|
+
'error_rate': f"{stats.error_rate:.2f}%",
|
|
517
|
+
'cache_hit_rate': f"{stats.cache_hit_rate}%",
|
|
518
|
+
'memory_peak_mb': naturalsize(stats.memory_peak) if stats.memory_peak > 0 else "0 B",
|
|
519
|
+
'last_called': stats.recent_call.isoformat() if stats.recent_call else None,
|
|
520
|
+
'recent_execution': stats.recent_call.isoformat() if stats.recent_call else None,
|
|
521
|
+
'is_cached': hasattr(func, 'cache_info'),
|
|
522
|
+
'cache_info': func.cache_info() if hasattr(func, 'cache_info') else None
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
def get_all_stats(self) -> Dict[str, Dict[str, Any]]:
|
|
526
|
+
"""Get statistics for all monitored functions."""
|
|
527
|
+
with self._lock:
|
|
528
|
+
return {
|
|
529
|
+
name: self.get_stats(name)
|
|
530
|
+
for name in self._monitored_procedures.keys()
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
def get_execution_history(
|
|
534
|
+
self,
|
|
535
|
+
name: str | None = None,
|
|
536
|
+
category: MetricType | None = None,
|
|
537
|
+
limit: int | None = None
|
|
538
|
+
) -> list[ExecutionMetrics]:
|
|
539
|
+
"""Get execution history filtered by procedure name or category."""
|
|
540
|
+
with self._lock:
|
|
541
|
+
if name:
|
|
542
|
+
history = self._execution_history.get(name, [])
|
|
543
|
+
else:
|
|
544
|
+
history = [m for h in self._execution_history.values() for m in h]
|
|
545
|
+
|
|
546
|
+
if category:
|
|
547
|
+
history = [m for m in history if m.category == category]
|
|
548
|
+
|
|
549
|
+
history.sort(key=lambda m: m.start_time or 0)
|
|
550
|
+
return history[-limit:] if limit else history
|
|
551
|
+
|
|
552
|
+
def clear_history(self, procedure: str | None = None) -> None:
|
|
553
|
+
"""Clear execution history."""
|
|
554
|
+
with self._lock:
|
|
555
|
+
if procedure:
|
|
556
|
+
if procedure in self._execution_history:
|
|
557
|
+
self._execution_history[procedure].clear()
|
|
558
|
+
if procedure in self._aggregated_stats:
|
|
559
|
+
self._aggregated_stats[procedure] = AggregatedStats()
|
|
560
|
+
else:
|
|
561
|
+
self._execution_history.clear()
|
|
562
|
+
self._aggregated_stats.clear()
|
|
563
|
+
|
|
564
|
+
def export_metrics(self, output_format: str = 'dict') -> Union[Dict[str, Any], str]:
|
|
565
|
+
"""
|
|
566
|
+
Export all metrics in various formats.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
output_format (str): Format for exporting metrics ('dict' or 'json').
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
Union[Dict[str, Any], str]: Exported metrics in the specified format.
|
|
573
|
+
"""
|
|
574
|
+
with self._lock:
|
|
575
|
+
data = {
|
|
576
|
+
'timestamp': datetime.now().isoformat(),
|
|
577
|
+
'functions': self.get_all_stats(),
|
|
578
|
+
'total_executions': sum(
|
|
579
|
+
len(history) for history in self._execution_history.values()
|
|
580
|
+
),
|
|
581
|
+
'collectors': [type(c).__name__ for c in self._collectors]
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if output_format == 'dict':
|
|
585
|
+
return data
|
|
586
|
+
elif output_format == 'json':
|
|
587
|
+
import json
|
|
588
|
+
return json.dumps(data, indent=2, default=str)
|
|
589
|
+
else:
|
|
590
|
+
raise ValueError(f"Unsupported format: {output_format}")
|
|
591
|
+
|
|
592
|
+
def cleanup(self):
|
|
593
|
+
"""Cleanup resources."""
|
|
594
|
+
with self._lock:
|
|
595
|
+
for collector in self._collectors:
|
|
596
|
+
if hasattr(collector, 'cleanup'):
|
|
597
|
+
try:
|
|
598
|
+
collector.cleanup()
|
|
599
|
+
except Exception as e:
|
|
600
|
+
logger.warning(f"Collector cleanup failed: {e}")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
MonitoringAspect = FunctionMonitor()
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _make_hashable(obj):
|
|
607
|
+
"""Convert potentially unhashable objects to a hashable representation."""
|
|
608
|
+
if isinstance(obj, defaultdict):
|
|
609
|
+
return 'defaultdict', _make_hashable(dict(obj))
|
|
610
|
+
|
|
611
|
+
elif isinstance(obj, dict):
|
|
612
|
+
return tuple(sorted((k, _make_hashable(v)) for k, v in obj.items()))
|
|
613
|
+
|
|
614
|
+
elif isinstance(obj, (list, set, tuple)):
|
|
615
|
+
return tuple(_make_hashable(v) for v in obj)
|
|
616
|
+
|
|
617
|
+
return obj
|