kubectl-mcp-server 1.16.0__py3-none-any.whl → 1.18.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.
- {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/METADATA +48 -1
- {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/RECORD +30 -15
- kubectl_mcp_tool/__init__.py +1 -1
- kubectl_mcp_tool/cli/cli.py +83 -9
- kubectl_mcp_tool/cli/output.py +14 -0
- kubectl_mcp_tool/config/__init__.py +46 -0
- kubectl_mcp_tool/config/loader.py +386 -0
- kubectl_mcp_tool/config/schema.py +184 -0
- kubectl_mcp_tool/k8s_config.py +127 -1
- kubectl_mcp_tool/mcp_server.py +219 -8
- kubectl_mcp_tool/observability/__init__.py +59 -0
- kubectl_mcp_tool/observability/metrics.py +223 -0
- kubectl_mcp_tool/observability/stats.py +255 -0
- kubectl_mcp_tool/observability/tracing.py +335 -0
- kubectl_mcp_tool/prompts/__init__.py +43 -0
- kubectl_mcp_tool/prompts/builtin.py +695 -0
- kubectl_mcp_tool/prompts/custom.py +298 -0
- kubectl_mcp_tool/prompts/prompts.py +180 -4
- kubectl_mcp_tool/providers.py +347 -0
- kubectl_mcp_tool/safety.py +155 -0
- kubectl_mcp_tool/tools/cluster.py +384 -0
- tests/test_config.py +386 -0
- tests/test_mcp_integration.py +251 -0
- tests/test_observability.py +521 -0
- tests/test_prompts.py +716 -0
- tests/test_safety.py +218 -0
- {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/WHEEL +0 -0
- {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/entry_points.txt +0 -0
- {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/licenses/LICENSE +0 -0
- {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prometheus metrics for kubectl-mcp-server.
|
|
3
|
+
|
|
4
|
+
Provides standard Prometheus format metrics for production monitoring.
|
|
5
|
+
|
|
6
|
+
Metrics exposed:
|
|
7
|
+
- mcp_tool_calls_total: Counter of tool invocations (labels: tool_name, status)
|
|
8
|
+
- mcp_tool_errors_total: Counter of tool errors (labels: tool_name, error_type)
|
|
9
|
+
- mcp_tool_duration_seconds: Histogram of tool call durations (labels: tool_name)
|
|
10
|
+
- mcp_http_requests_total: Counter of HTTP requests (labels: endpoint, method, status)
|
|
11
|
+
- mcp_server_info: Gauge with server metadata
|
|
12
|
+
|
|
13
|
+
Requires: prometheus-client>=0.19.0 (optional dependency)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Check if prometheus_client is available
|
|
22
|
+
_prometheus_available = False
|
|
23
|
+
_REGISTRY = None
|
|
24
|
+
_tool_calls_counter = None
|
|
25
|
+
_tool_errors_counter = None
|
|
26
|
+
_tool_duration_histogram = None
|
|
27
|
+
_http_requests_counter = None
|
|
28
|
+
_server_info_gauge = None
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from prometheus_client import (
|
|
32
|
+
Counter,
|
|
33
|
+
Histogram,
|
|
34
|
+
Gauge,
|
|
35
|
+
CollectorRegistry,
|
|
36
|
+
generate_latest,
|
|
37
|
+
CONTENT_TYPE_LATEST,
|
|
38
|
+
)
|
|
39
|
+
_prometheus_available = True
|
|
40
|
+
|
|
41
|
+
# Create a custom registry to avoid conflicts
|
|
42
|
+
_REGISTRY = CollectorRegistry()
|
|
43
|
+
|
|
44
|
+
# Tool call counter
|
|
45
|
+
_tool_calls_counter = Counter(
|
|
46
|
+
"mcp_tool_calls_total",
|
|
47
|
+
"Total number of MCP tool calls",
|
|
48
|
+
["tool_name", "status"],
|
|
49
|
+
registry=_REGISTRY,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Tool error counter
|
|
53
|
+
_tool_errors_counter = Counter(
|
|
54
|
+
"mcp_tool_errors_total",
|
|
55
|
+
"Total number of MCP tool errors",
|
|
56
|
+
["tool_name", "error_type"],
|
|
57
|
+
registry=_REGISTRY,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Tool duration histogram
|
|
61
|
+
# Buckets optimized for typical k8s API call durations
|
|
62
|
+
_tool_duration_histogram = Histogram(
|
|
63
|
+
"mcp_tool_duration_seconds",
|
|
64
|
+
"Duration of MCP tool calls in seconds",
|
|
65
|
+
["tool_name"],
|
|
66
|
+
buckets=(0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0),
|
|
67
|
+
registry=_REGISTRY,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# HTTP requests counter
|
|
71
|
+
_http_requests_counter = Counter(
|
|
72
|
+
"mcp_http_requests_total",
|
|
73
|
+
"Total number of HTTP requests",
|
|
74
|
+
["endpoint", "method", "status"],
|
|
75
|
+
registry=_REGISTRY,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Server info gauge (version, features)
|
|
79
|
+
_server_info_gauge = Gauge(
|
|
80
|
+
"mcp_server_info",
|
|
81
|
+
"MCP server information",
|
|
82
|
+
["version", "transport"],
|
|
83
|
+
registry=_REGISTRY,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
logger.debug("Prometheus metrics initialized successfully")
|
|
87
|
+
|
|
88
|
+
except ImportError:
|
|
89
|
+
logger.debug(
|
|
90
|
+
"prometheus_client not installed. Prometheus metrics disabled. "
|
|
91
|
+
"Install with: pip install kubectl-mcp-server[observability]"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_prometheus_available() -> bool:
|
|
96
|
+
"""Check if Prometheus client is available."""
|
|
97
|
+
return _prometheus_available
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def record_tool_call_metric(
|
|
101
|
+
tool_name: str,
|
|
102
|
+
success: bool = True,
|
|
103
|
+
duration: float = 0.0
|
|
104
|
+
) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Record a tool call in Prometheus metrics.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
tool_name: Name of the tool called
|
|
110
|
+
success: Whether the call succeeded
|
|
111
|
+
duration: Call duration in seconds
|
|
112
|
+
"""
|
|
113
|
+
if not _prometheus_available:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
status = "success" if success else "error"
|
|
117
|
+
_tool_calls_counter.labels(tool_name=tool_name, status=status).inc()
|
|
118
|
+
|
|
119
|
+
if duration > 0:
|
|
120
|
+
_tool_duration_histogram.labels(tool_name=tool_name).observe(duration)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def record_tool_error_metric(
|
|
124
|
+
tool_name: str,
|
|
125
|
+
error_type: str = "unknown"
|
|
126
|
+
) -> None:
|
|
127
|
+
"""
|
|
128
|
+
Record a tool error in Prometheus metrics.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
tool_name: Name of the tool that errored
|
|
132
|
+
error_type: Type/category of error (e.g., "timeout", "validation", "k8s_api")
|
|
133
|
+
"""
|
|
134
|
+
if not _prometheus_available:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
_tool_errors_counter.labels(
|
|
138
|
+
tool_name=tool_name,
|
|
139
|
+
error_type=error_type
|
|
140
|
+
).inc()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def record_tool_duration_metric(tool_name: str, duration: float) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Record tool duration in Prometheus histogram.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
tool_name: Name of the tool
|
|
149
|
+
duration: Duration in seconds
|
|
150
|
+
"""
|
|
151
|
+
if not _prometheus_available:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
_tool_duration_histogram.labels(tool_name=tool_name).observe(duration)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def record_http_request_metric(
|
|
158
|
+
endpoint: str,
|
|
159
|
+
method: str,
|
|
160
|
+
status: int = 200
|
|
161
|
+
) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Record an HTTP request in Prometheus metrics.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
endpoint: Request endpoint path
|
|
167
|
+
method: HTTP method
|
|
168
|
+
status: HTTP status code
|
|
169
|
+
"""
|
|
170
|
+
if not _prometheus_available:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
_http_requests_counter.labels(
|
|
174
|
+
endpoint=endpoint,
|
|
175
|
+
method=method,
|
|
176
|
+
status=str(status)
|
|
177
|
+
).inc()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def set_server_info(version: str, transport: str) -> None:
|
|
181
|
+
"""
|
|
182
|
+
Set server info in Prometheus gauge.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
version: Server version
|
|
186
|
+
transport: Transport type (stdio, sse, http)
|
|
187
|
+
"""
|
|
188
|
+
if not _prometheus_available:
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
_server_info_gauge.labels(version=version, transport=transport).set(1)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_metrics() -> str:
|
|
195
|
+
"""
|
|
196
|
+
Get metrics in Prometheus text format.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Prometheus metrics as text, or error message if unavailable
|
|
200
|
+
"""
|
|
201
|
+
if not _prometheus_available:
|
|
202
|
+
return (
|
|
203
|
+
"# Prometheus metrics not available.\n"
|
|
204
|
+
"# Install with: pip install kubectl-mcp-server[observability]\n"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
return generate_latest(_REGISTRY).decode("utf-8")
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.error(f"Error generating Prometheus metrics: {e}")
|
|
211
|
+
return f"# Error generating metrics: {e}\n"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_metrics_content_type() -> str:
|
|
215
|
+
"""
|
|
216
|
+
Get the content type for Prometheus metrics.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Prometheus content type string
|
|
220
|
+
"""
|
|
221
|
+
if not _prometheus_available:
|
|
222
|
+
return "text/plain; charset=utf-8"
|
|
223
|
+
return CONTENT_TYPE_LATEST
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime statistics collection for kubectl-mcp-server.
|
|
3
|
+
|
|
4
|
+
Provides a singleton StatsCollector that tracks:
|
|
5
|
+
- tool_calls_total: Total number of tool invocations
|
|
6
|
+
- tool_errors_total: Total number of tool errors
|
|
7
|
+
- tool_calls_by_name: Breakdown of calls by tool name
|
|
8
|
+
- http_requests_total: Total HTTP requests (for SSE/HTTP transports)
|
|
9
|
+
- uptime: Server uptime in seconds
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
import threading
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Dict, Any, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ToolStats:
|
|
21
|
+
"""Statistics for a single tool."""
|
|
22
|
+
calls: int = 0
|
|
23
|
+
errors: int = 0
|
|
24
|
+
total_duration: float = 0.0
|
|
25
|
+
last_call_time: Optional[float] = None
|
|
26
|
+
last_error_time: Optional[float] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StatsCollector:
|
|
30
|
+
"""
|
|
31
|
+
Singleton class for collecting runtime statistics.
|
|
32
|
+
|
|
33
|
+
Thread-safe statistics collection for production observability.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
stats = get_stats_collector()
|
|
37
|
+
stats.record_tool_call("get_pods", success=True, duration=0.5)
|
|
38
|
+
|
|
39
|
+
# Get current stats
|
|
40
|
+
data = stats.get_stats()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
_instance: Optional["StatsCollector"] = None
|
|
44
|
+
_lock = threading.Lock()
|
|
45
|
+
|
|
46
|
+
def __new__(cls) -> "StatsCollector":
|
|
47
|
+
"""Ensure singleton pattern."""
|
|
48
|
+
if cls._instance is None:
|
|
49
|
+
with cls._lock:
|
|
50
|
+
if cls._instance is None:
|
|
51
|
+
cls._instance = super().__new__(cls)
|
|
52
|
+
cls._instance._initialized = False
|
|
53
|
+
return cls._instance
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
"""Initialize the stats collector (only once)."""
|
|
57
|
+
if self._initialized:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
self._stats_lock = threading.Lock()
|
|
61
|
+
self._start_time = time.time()
|
|
62
|
+
|
|
63
|
+
# Core counters
|
|
64
|
+
self._tool_calls_total = 0
|
|
65
|
+
self._tool_errors_total = 0
|
|
66
|
+
self._http_requests_total = 0
|
|
67
|
+
|
|
68
|
+
# Per-tool statistics
|
|
69
|
+
self._tool_stats: Dict[str, ToolStats] = defaultdict(ToolStats)
|
|
70
|
+
|
|
71
|
+
# HTTP request breakdown
|
|
72
|
+
self._http_requests_by_endpoint: Dict[str, int] = defaultdict(int)
|
|
73
|
+
self._http_requests_by_method: Dict[str, int] = defaultdict(int)
|
|
74
|
+
|
|
75
|
+
self._initialized = True
|
|
76
|
+
|
|
77
|
+
def record_tool_call(
|
|
78
|
+
self,
|
|
79
|
+
tool_name: str,
|
|
80
|
+
success: bool = True,
|
|
81
|
+
duration: float = 0.0
|
|
82
|
+
) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Record a tool call.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
tool_name: Name of the tool that was called
|
|
88
|
+
success: Whether the call succeeded
|
|
89
|
+
duration: Call duration in seconds
|
|
90
|
+
"""
|
|
91
|
+
with self._stats_lock:
|
|
92
|
+
self._tool_calls_total += 1
|
|
93
|
+
|
|
94
|
+
stats = self._tool_stats[tool_name]
|
|
95
|
+
stats.calls += 1
|
|
96
|
+
stats.total_duration += duration
|
|
97
|
+
stats.last_call_time = time.time()
|
|
98
|
+
|
|
99
|
+
if not success:
|
|
100
|
+
self._tool_errors_total += 1
|
|
101
|
+
stats.errors += 1
|
|
102
|
+
stats.last_error_time = time.time()
|
|
103
|
+
|
|
104
|
+
def record_tool_error(self, tool_name: str) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Record a tool error (shorthand for failed call).
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
tool_name: Name of the tool that errored
|
|
110
|
+
"""
|
|
111
|
+
self.record_tool_call(tool_name, success=False)
|
|
112
|
+
|
|
113
|
+
def record_http_request(
|
|
114
|
+
self,
|
|
115
|
+
endpoint: str = "/",
|
|
116
|
+
method: str = "POST"
|
|
117
|
+
) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Record an HTTP request.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
endpoint: Request endpoint path
|
|
123
|
+
method: HTTP method (GET, POST, etc.)
|
|
124
|
+
"""
|
|
125
|
+
with self._stats_lock:
|
|
126
|
+
self._http_requests_total += 1
|
|
127
|
+
self._http_requests_by_endpoint[endpoint] += 1
|
|
128
|
+
self._http_requests_by_method[method] += 1
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def uptime(self) -> float:
|
|
132
|
+
"""Get server uptime in seconds."""
|
|
133
|
+
return time.time() - self._start_time
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def tool_calls_total(self) -> int:
|
|
137
|
+
"""Get total tool calls."""
|
|
138
|
+
with self._stats_lock:
|
|
139
|
+
return self._tool_calls_total
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def tool_errors_total(self) -> int:
|
|
143
|
+
"""Get total tool errors."""
|
|
144
|
+
with self._stats_lock:
|
|
145
|
+
return self._tool_errors_total
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def http_requests_total(self) -> int:
|
|
149
|
+
"""Get total HTTP requests."""
|
|
150
|
+
with self._stats_lock:
|
|
151
|
+
return self._http_requests_total
|
|
152
|
+
|
|
153
|
+
def get_tool_stats(self, tool_name: str) -> Optional[Dict[str, Any]]:
|
|
154
|
+
"""
|
|
155
|
+
Get statistics for a specific tool.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
tool_name: Name of the tool
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Dictionary with tool statistics or None if not found
|
|
162
|
+
"""
|
|
163
|
+
with self._stats_lock:
|
|
164
|
+
if tool_name not in self._tool_stats:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
stats = self._tool_stats[tool_name]
|
|
168
|
+
avg_duration = (
|
|
169
|
+
stats.total_duration / stats.calls
|
|
170
|
+
if stats.calls > 0 else 0.0
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"calls": stats.calls,
|
|
175
|
+
"errors": stats.errors,
|
|
176
|
+
"error_rate": stats.errors / stats.calls if stats.calls > 0 else 0.0,
|
|
177
|
+
"total_duration_seconds": stats.total_duration,
|
|
178
|
+
"average_duration_seconds": avg_duration,
|
|
179
|
+
"last_call_time": stats.last_call_time,
|
|
180
|
+
"last_error_time": stats.last_error_time,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
184
|
+
"""
|
|
185
|
+
Get all statistics as a JSON-serializable dictionary.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Dictionary containing all collected statistics
|
|
189
|
+
"""
|
|
190
|
+
with self._stats_lock:
|
|
191
|
+
# Calculate tool-level stats
|
|
192
|
+
tool_stats_dict = {}
|
|
193
|
+
for tool_name, stats in self._tool_stats.items():
|
|
194
|
+
avg_duration = (
|
|
195
|
+
stats.total_duration / stats.calls
|
|
196
|
+
if stats.calls > 0 else 0.0
|
|
197
|
+
)
|
|
198
|
+
tool_stats_dict[tool_name] = {
|
|
199
|
+
"calls": stats.calls,
|
|
200
|
+
"errors": stats.errors,
|
|
201
|
+
"error_rate": stats.errors / stats.calls if stats.calls > 0 else 0.0,
|
|
202
|
+
"average_duration_seconds": round(avg_duration, 4),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# Sort tools by call count (descending)
|
|
206
|
+
sorted_tools = dict(
|
|
207
|
+
sorted(
|
|
208
|
+
tool_stats_dict.items(),
|
|
209
|
+
key=lambda x: x[1]["calls"],
|
|
210
|
+
reverse=True
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
"uptime_seconds": round(self.uptime, 2),
|
|
216
|
+
"tool_calls_total": self._tool_calls_total,
|
|
217
|
+
"tool_errors_total": self._tool_errors_total,
|
|
218
|
+
"tool_error_rate": (
|
|
219
|
+
self._tool_errors_total / self._tool_calls_total
|
|
220
|
+
if self._tool_calls_total > 0 else 0.0
|
|
221
|
+
),
|
|
222
|
+
"http_requests_total": self._http_requests_total,
|
|
223
|
+
"http_requests_by_endpoint": dict(self._http_requests_by_endpoint),
|
|
224
|
+
"http_requests_by_method": dict(self._http_requests_by_method),
|
|
225
|
+
"tool_calls_by_name": sorted_tools,
|
|
226
|
+
"unique_tools_called": len(self._tool_stats),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def reset(self) -> None:
|
|
230
|
+
"""Reset all statistics (useful for testing)."""
|
|
231
|
+
with self._stats_lock:
|
|
232
|
+
self._start_time = time.time()
|
|
233
|
+
self._tool_calls_total = 0
|
|
234
|
+
self._tool_errors_total = 0
|
|
235
|
+
self._http_requests_total = 0
|
|
236
|
+
self._tool_stats.clear()
|
|
237
|
+
self._http_requests_by_endpoint.clear()
|
|
238
|
+
self._http_requests_by_method.clear()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# Module-level singleton accessor
|
|
242
|
+
_stats_collector: Optional[StatsCollector] = None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_stats_collector() -> StatsCollector:
|
|
246
|
+
"""
|
|
247
|
+
Get the singleton StatsCollector instance.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
The global StatsCollector instance
|
|
251
|
+
"""
|
|
252
|
+
global _stats_collector
|
|
253
|
+
if _stats_collector is None:
|
|
254
|
+
_stats_collector = StatsCollector()
|
|
255
|
+
return _stats_collector
|