foundry-mcp 0.8.22__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.
Potentially problematic release.
This version of foundry-mcp might be problematic. Click here for more details.
- foundry_mcp/__init__.py +13 -0
- foundry_mcp/cli/__init__.py +67 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +640 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +667 -0
- foundry_mcp/cli/commands/session.py +472 -0
- foundry_mcp/cli/commands/specs.py +686 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +298 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +1454 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1773 -0
- foundry_mcp/core/batch_operations.py +1202 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/journal.py +700 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1376 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +146 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +387 -0
- foundry_mcp/core/prometheus.py +564 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +691 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
- foundry_mcp/core/prompts/plan_review.py +627 -0
- foundry_mcp/core/providers/__init__.py +237 -0
- foundry_mcp/core/providers/base.py +515 -0
- foundry_mcp/core/providers/claude.py +472 -0
- foundry_mcp/core/providers/codex.py +637 -0
- foundry_mcp/core/providers/cursor_agent.py +630 -0
- foundry_mcp/core/providers/detectors.py +515 -0
- foundry_mcp/core/providers/gemini.py +426 -0
- foundry_mcp/core/providers/opencode.py +718 -0
- foundry_mcp/core/providers/opencode_wrapper.js +308 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +857 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1234 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4142 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +1624 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +4119 -0
- foundry_mcp/core/task.py +2463 -0
- foundry_mcp/core/testing.py +839 -0
- foundry_mcp/core/validation.py +2357 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +177 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +300 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +164 -0
- foundry_mcp/dashboard/views/overview.py +96 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +414 -0
- foundry_mcp/server.py +150 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +92 -0
- foundry_mcp/tools/unified/authoring.py +3620 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +268 -0
- foundry_mcp/tools/unified/environment.py +1341 -0
- foundry_mcp/tools/unified/error.py +479 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +640 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +876 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +589 -0
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +1042 -0
- foundry_mcp/tools/unified/review_helpers.py +314 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +565 -0
- foundry_mcp/tools/unified/spec.py +1283 -0
- foundry_mcp/tools/unified/task.py +3846 -0
- foundry_mcp/tools/unified/test.py +431 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.8.22.dist-info/METADATA +344 -0
- foundry_mcp-0.8.22.dist-info/RECORD +153 -0
- foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
- foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
- foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Metrics persistence collector for the foundry-mcp server.
|
|
3
|
+
|
|
4
|
+
Hooks into PrometheusExporter to capture metrics and persist them
|
|
5
|
+
to disk with time-bucket aggregation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from foundry_mcp.config import MetricsPersistenceConfig
|
|
18
|
+
from foundry_mcp.core.metrics_store import (
|
|
19
|
+
MetricDataPoint,
|
|
20
|
+
FileMetricsStore,
|
|
21
|
+
MetricsStore,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class MetricBucket:
|
|
29
|
+
"""
|
|
30
|
+
Aggregated metrics bucket for a time period.
|
|
31
|
+
|
|
32
|
+
Collects multiple samples within a time window and aggregates them
|
|
33
|
+
for storage efficiency.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
metric_name: str
|
|
37
|
+
metric_type: str
|
|
38
|
+
labels: dict[str, str]
|
|
39
|
+
bucket_start: datetime
|
|
40
|
+
bucket_end: datetime
|
|
41
|
+
values: list[float] = field(default_factory=list)
|
|
42
|
+
sample_count: int = 0
|
|
43
|
+
|
|
44
|
+
def add_sample(self, value: float) -> None:
|
|
45
|
+
"""Add a sample to the bucket."""
|
|
46
|
+
self.values.append(value)
|
|
47
|
+
self.sample_count += 1
|
|
48
|
+
|
|
49
|
+
def get_aggregated_value(self) -> float:
|
|
50
|
+
"""Get the aggregated value based on metric type."""
|
|
51
|
+
if not self.values:
|
|
52
|
+
return 0.0
|
|
53
|
+
|
|
54
|
+
if self.metric_type == "counter":
|
|
55
|
+
# For counters, sum all increments
|
|
56
|
+
return sum(self.values)
|
|
57
|
+
elif self.metric_type == "gauge":
|
|
58
|
+
# For gauges, use the last value
|
|
59
|
+
return self.values[-1]
|
|
60
|
+
elif self.metric_type == "histogram":
|
|
61
|
+
# For histograms, store average of observed values
|
|
62
|
+
return sum(self.values) / len(self.values) if self.values else 0.0
|
|
63
|
+
else:
|
|
64
|
+
# Default: sum
|
|
65
|
+
return sum(self.values)
|
|
66
|
+
|
|
67
|
+
def to_data_point(self) -> MetricDataPoint:
|
|
68
|
+
"""Convert bucket to a MetricDataPoint."""
|
|
69
|
+
return MetricDataPoint(
|
|
70
|
+
metric_name=self.metric_name,
|
|
71
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
72
|
+
value=self.get_aggregated_value(),
|
|
73
|
+
metric_type=self.metric_type,
|
|
74
|
+
labels=self.labels,
|
|
75
|
+
bucket_start=self.bucket_start.isoformat(),
|
|
76
|
+
bucket_end=self.bucket_end.isoformat(),
|
|
77
|
+
sample_count=self.sample_count,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class MetricsPersistenceCollector:
|
|
82
|
+
"""
|
|
83
|
+
Collects metrics and persists them to storage.
|
|
84
|
+
|
|
85
|
+
Features:
|
|
86
|
+
- In-memory buffering with periodic flush
|
|
87
|
+
- Time-bucket aggregation to reduce storage
|
|
88
|
+
- Configurable metric filtering
|
|
89
|
+
- Thread-safe operation
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
config: MetricsPersistenceConfig,
|
|
95
|
+
store: Optional[MetricsStore] = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Initialize the collector.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
config: Metrics persistence configuration
|
|
102
|
+
store: Optional MetricsStore (uses global singleton if not provided)
|
|
103
|
+
"""
|
|
104
|
+
self._config = config
|
|
105
|
+
self._store = store
|
|
106
|
+
|
|
107
|
+
# In-memory buffer: key = (metric_name, labels_tuple), value = MetricBucket
|
|
108
|
+
self._buffer: dict[tuple[str, tuple[tuple[str, str], ...]], MetricBucket] = {}
|
|
109
|
+
self._buffer_lock = threading.Lock()
|
|
110
|
+
|
|
111
|
+
# Bucket timing - set interval before using _get_bucket_start
|
|
112
|
+
self._bucket_interval = config.bucket_interval_seconds
|
|
113
|
+
self._current_bucket_start = self._get_bucket_start(datetime.now(timezone.utc))
|
|
114
|
+
|
|
115
|
+
# Flush timing
|
|
116
|
+
self._last_flush = time.time()
|
|
117
|
+
self._flush_interval = config.flush_interval_seconds
|
|
118
|
+
|
|
119
|
+
# Background flush thread
|
|
120
|
+
self._flush_thread: Optional[threading.Thread] = None
|
|
121
|
+
self._shutdown = threading.Event()
|
|
122
|
+
|
|
123
|
+
# Start background flush if enabled
|
|
124
|
+
if config.enabled:
|
|
125
|
+
self._start_flush_thread()
|
|
126
|
+
# Note: atexit handler removed to avoid premature shutdown in MCP stdio mode
|
|
127
|
+
# The server's shutdown() method should call _shutdown_flush_thread explicitly
|
|
128
|
+
|
|
129
|
+
def _get_store(self) -> MetricsStore:
|
|
130
|
+
"""Get the metrics store (lazy initialization)."""
|
|
131
|
+
if self._store is None:
|
|
132
|
+
storage_path = self._config.get_storage_path()
|
|
133
|
+
self._store = FileMetricsStore(storage_path)
|
|
134
|
+
return self._store
|
|
135
|
+
|
|
136
|
+
def _get_bucket_start(self, dt: datetime) -> datetime:
|
|
137
|
+
"""Get the start of the bucket containing the given datetime."""
|
|
138
|
+
# Round down to nearest bucket interval
|
|
139
|
+
timestamp = dt.timestamp()
|
|
140
|
+
bucket_start_ts = (timestamp // self._bucket_interval) * self._bucket_interval
|
|
141
|
+
return datetime.fromtimestamp(bucket_start_ts, tz=timezone.utc)
|
|
142
|
+
|
|
143
|
+
def _get_bucket_key(
|
|
144
|
+
self,
|
|
145
|
+
metric_name: str,
|
|
146
|
+
labels: dict[str, str],
|
|
147
|
+
) -> tuple[str, tuple[tuple[str, str], ...]]:
|
|
148
|
+
"""Create a hashable key for the bucket."""
|
|
149
|
+
# Sort labels for consistent hashing
|
|
150
|
+
labels_tuple = tuple(sorted(labels.items()))
|
|
151
|
+
return (metric_name, labels_tuple)
|
|
152
|
+
|
|
153
|
+
def _should_persist(self, metric_name: str) -> bool:
|
|
154
|
+
"""Check if this metric should be persisted."""
|
|
155
|
+
if not self._config.enabled:
|
|
156
|
+
return False
|
|
157
|
+
return self._config.should_persist_metric(metric_name)
|
|
158
|
+
|
|
159
|
+
def record(
|
|
160
|
+
self,
|
|
161
|
+
metric_name: str,
|
|
162
|
+
value: float,
|
|
163
|
+
metric_type: str = "counter",
|
|
164
|
+
labels: Optional[dict[str, str]] = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Record a metric value.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
metric_name: Name of the metric
|
|
171
|
+
value: Metric value
|
|
172
|
+
metric_type: Type of metric (counter, gauge, histogram)
|
|
173
|
+
labels: Label key-value pairs
|
|
174
|
+
"""
|
|
175
|
+
if not self._should_persist(metric_name):
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
labels = labels or {}
|
|
179
|
+
now = datetime.now(timezone.utc)
|
|
180
|
+
bucket_start = self._get_bucket_start(now)
|
|
181
|
+
bucket_end = datetime.fromtimestamp(
|
|
182
|
+
bucket_start.timestamp() + self._bucket_interval,
|
|
183
|
+
tz=timezone.utc,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
with self._buffer_lock:
|
|
187
|
+
key = self._get_bucket_key(metric_name, labels)
|
|
188
|
+
|
|
189
|
+
# Create new bucket if needed or bucket has rolled over
|
|
190
|
+
if key not in self._buffer or self._buffer[key].bucket_start != bucket_start:
|
|
191
|
+
# Flush old bucket if exists
|
|
192
|
+
if key in self._buffer:
|
|
193
|
+
self._flush_bucket(key)
|
|
194
|
+
|
|
195
|
+
self._buffer[key] = MetricBucket(
|
|
196
|
+
metric_name=metric_name,
|
|
197
|
+
metric_type=metric_type,
|
|
198
|
+
labels=labels,
|
|
199
|
+
bucket_start=bucket_start,
|
|
200
|
+
bucket_end=bucket_end,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
self._buffer[key].add_sample(value)
|
|
204
|
+
|
|
205
|
+
def _flush_bucket(self, key: tuple[str, tuple[tuple[str, str], ...]]) -> None:
|
|
206
|
+
"""Flush a single bucket to storage (caller holds lock)."""
|
|
207
|
+
if key not in self._buffer:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
bucket = self._buffer[key]
|
|
211
|
+
if bucket.sample_count == 0:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
data_point = bucket.to_data_point()
|
|
216
|
+
self._get_store().append(data_point)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.warning(f"Failed to flush metric bucket: {e}")
|
|
219
|
+
|
|
220
|
+
def flush(self) -> int:
|
|
221
|
+
"""
|
|
222
|
+
Flush all buffered metrics to storage.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Number of data points flushed
|
|
226
|
+
"""
|
|
227
|
+
flushed = 0
|
|
228
|
+
|
|
229
|
+
with self._buffer_lock:
|
|
230
|
+
# Collect all buckets with data
|
|
231
|
+
buckets_to_flush = [
|
|
232
|
+
bucket.to_data_point()
|
|
233
|
+
for bucket in self._buffer.values()
|
|
234
|
+
if bucket.sample_count > 0
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
# Clear buffer
|
|
238
|
+
self._buffer.clear()
|
|
239
|
+
self._last_flush = time.time()
|
|
240
|
+
|
|
241
|
+
# Flush outside the lock to avoid holding it during I/O
|
|
242
|
+
if buckets_to_flush:
|
|
243
|
+
try:
|
|
244
|
+
self._get_store().append_batch(buckets_to_flush)
|
|
245
|
+
flushed = len(buckets_to_flush)
|
|
246
|
+
logger.debug(f"Flushed {flushed} metric buckets to storage")
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.warning(f"Failed to flush metrics batch: {e}")
|
|
249
|
+
|
|
250
|
+
return flushed
|
|
251
|
+
|
|
252
|
+
def _start_flush_thread(self) -> None:
|
|
253
|
+
"""Start the background flush thread."""
|
|
254
|
+
if self._flush_thread is not None:
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
self._flush_thread = threading.Thread(
|
|
258
|
+
target=self._flush_loop,
|
|
259
|
+
name="metrics-persistence-flush",
|
|
260
|
+
daemon=True,
|
|
261
|
+
)
|
|
262
|
+
self._flush_thread.start()
|
|
263
|
+
|
|
264
|
+
def _flush_loop(self) -> None:
|
|
265
|
+
"""Background loop to periodically flush metrics."""
|
|
266
|
+
while not self._shutdown.wait(timeout=self._flush_interval):
|
|
267
|
+
try:
|
|
268
|
+
self.flush()
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.warning(f"Error in metrics flush loop: {e}")
|
|
271
|
+
|
|
272
|
+
# Final flush on shutdown
|
|
273
|
+
try:
|
|
274
|
+
self.flush()
|
|
275
|
+
except Exception as e:
|
|
276
|
+
logger.warning(f"Error in final metrics flush: {e}")
|
|
277
|
+
|
|
278
|
+
def _shutdown_flush_thread(self) -> None:
|
|
279
|
+
"""Shutdown the background flush thread."""
|
|
280
|
+
self._shutdown.set()
|
|
281
|
+
if self._flush_thread is not None:
|
|
282
|
+
self._flush_thread.join(timeout=5.0)
|
|
283
|
+
self._flush_thread = None
|
|
284
|
+
|
|
285
|
+
def shutdown(self) -> None:
|
|
286
|
+
"""Shutdown the collector, flushing any remaining data."""
|
|
287
|
+
self._shutdown_flush_thread()
|
|
288
|
+
|
|
289
|
+
def is_enabled(self) -> bool:
|
|
290
|
+
"""Check if persistence is enabled."""
|
|
291
|
+
return self._config.enabled
|
|
292
|
+
|
|
293
|
+
def get_buffer_size(self) -> int:
|
|
294
|
+
"""Get the current number of buffered buckets."""
|
|
295
|
+
with self._buffer_lock:
|
|
296
|
+
return len(self._buffer)
|
|
297
|
+
|
|
298
|
+
def get_sample_count(self) -> int:
|
|
299
|
+
"""Get the total number of samples in buffer."""
|
|
300
|
+
with self._buffer_lock:
|
|
301
|
+
return sum(b.sample_count for b in self._buffer.values())
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# =============================================================================
|
|
305
|
+
# PrometheusExporter Integration
|
|
306
|
+
# =============================================================================
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def create_persistence_aware_exporter(
|
|
310
|
+
config: MetricsPersistenceConfig,
|
|
311
|
+
collector: Optional[MetricsPersistenceCollector] = None,
|
|
312
|
+
) -> "PersistenceAwareExporter":
|
|
313
|
+
"""
|
|
314
|
+
Create a PrometheusExporter wrapper that also persists metrics.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
config: Metrics persistence configuration
|
|
318
|
+
collector: Optional collector (creates one if not provided)
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
PersistenceAwareExporter instance
|
|
322
|
+
"""
|
|
323
|
+
from foundry_mcp.core.prometheus import get_prometheus_exporter
|
|
324
|
+
|
|
325
|
+
if collector is None:
|
|
326
|
+
collector = MetricsPersistenceCollector(config)
|
|
327
|
+
|
|
328
|
+
return PersistenceAwareExporter(
|
|
329
|
+
exporter=get_prometheus_exporter(),
|
|
330
|
+
collector=collector,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class PersistenceAwareExporter:
|
|
335
|
+
"""
|
|
336
|
+
Wrapper around PrometheusExporter that also persists metrics.
|
|
337
|
+
|
|
338
|
+
Intercepts metric recording calls and forwards them to both
|
|
339
|
+
the underlying Prometheus exporter and the persistence collector.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
def __init__(
|
|
343
|
+
self,
|
|
344
|
+
exporter: Any, # PrometheusExporter
|
|
345
|
+
collector: MetricsPersistenceCollector,
|
|
346
|
+
) -> None:
|
|
347
|
+
"""
|
|
348
|
+
Initialize the wrapper.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
exporter: The underlying PrometheusExporter
|
|
352
|
+
collector: MetricsPersistenceCollector for persistence
|
|
353
|
+
"""
|
|
354
|
+
self._exporter = exporter
|
|
355
|
+
self._collector = collector
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def collector(self) -> MetricsPersistenceCollector:
|
|
359
|
+
"""Get the persistence collector."""
|
|
360
|
+
return self._collector
|
|
361
|
+
|
|
362
|
+
def record_tool_invocation(
|
|
363
|
+
self,
|
|
364
|
+
tool_name: str,
|
|
365
|
+
*,
|
|
366
|
+
success: bool = True,
|
|
367
|
+
duration_ms: Optional[float] = None,
|
|
368
|
+
) -> None:
|
|
369
|
+
"""Record a tool invocation."""
|
|
370
|
+
# Forward to Prometheus
|
|
371
|
+
self._exporter.record_tool_invocation(
|
|
372
|
+
tool_name, success=success, duration_ms=duration_ms
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Persist
|
|
376
|
+
status = "success" if success else "error"
|
|
377
|
+
self._collector.record(
|
|
378
|
+
"tool_invocations_total",
|
|
379
|
+
1.0,
|
|
380
|
+
metric_type="counter",
|
|
381
|
+
labels={"tool": tool_name, "status": status},
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if duration_ms is not None:
|
|
385
|
+
self._collector.record(
|
|
386
|
+
"tool_duration_seconds",
|
|
387
|
+
duration_ms / 1000.0,
|
|
388
|
+
metric_type="histogram",
|
|
389
|
+
labels={"tool": tool_name},
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def record_tool_start(self, tool_name: str) -> None:
|
|
393
|
+
"""Record tool execution start."""
|
|
394
|
+
self._exporter.record_tool_start(tool_name)
|
|
395
|
+
# Not persisted - gauge at point in time
|
|
396
|
+
|
|
397
|
+
def record_tool_end(self, tool_name: str) -> None:
|
|
398
|
+
"""Record tool execution end."""
|
|
399
|
+
self._exporter.record_tool_end(tool_name)
|
|
400
|
+
# Not persisted - gauge at point in time
|
|
401
|
+
|
|
402
|
+
def record_resource_access(
|
|
403
|
+
self,
|
|
404
|
+
resource_type: str,
|
|
405
|
+
action: str = "read",
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Record a resource access."""
|
|
408
|
+
self._exporter.record_resource_access(resource_type, action)
|
|
409
|
+
# Not in default persist list, but could be configured
|
|
410
|
+
|
|
411
|
+
def record_error(
|
|
412
|
+
self,
|
|
413
|
+
tool_name: str,
|
|
414
|
+
error_type: str = "unknown",
|
|
415
|
+
) -> None:
|
|
416
|
+
"""Record a tool error."""
|
|
417
|
+
self._exporter.record_error(tool_name, error_type)
|
|
418
|
+
|
|
419
|
+
self._collector.record(
|
|
420
|
+
"tool_errors_total",
|
|
421
|
+
1.0,
|
|
422
|
+
metric_type="counter",
|
|
423
|
+
labels={"tool": tool_name, "error_type": error_type},
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def record_health_check(
|
|
427
|
+
self,
|
|
428
|
+
check_type: str,
|
|
429
|
+
status: int,
|
|
430
|
+
duration_seconds: Optional[float] = None,
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Record a health check result."""
|
|
433
|
+
self._exporter.record_health_check(check_type, status, duration_seconds)
|
|
434
|
+
|
|
435
|
+
self._collector.record(
|
|
436
|
+
"health_status",
|
|
437
|
+
float(status),
|
|
438
|
+
metric_type="gauge",
|
|
439
|
+
labels={"check_type": check_type},
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def record_dependency_health(
|
|
443
|
+
self,
|
|
444
|
+
dependency: str,
|
|
445
|
+
healthy: bool,
|
|
446
|
+
) -> None:
|
|
447
|
+
"""Record dependency health status."""
|
|
448
|
+
self._exporter.record_dependency_health(dependency, healthy)
|
|
449
|
+
# Not persisted by default
|
|
450
|
+
|
|
451
|
+
def record_health_check_batch(
|
|
452
|
+
self,
|
|
453
|
+
check_type: str,
|
|
454
|
+
status: int,
|
|
455
|
+
dependencies: dict[str, bool],
|
|
456
|
+
duration_seconds: Optional[float] = None,
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Record a complete health check with all dependencies."""
|
|
459
|
+
self._exporter.record_health_check_batch(
|
|
460
|
+
check_type, status, dependencies, duration_seconds
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
self._collector.record(
|
|
464
|
+
"health_status",
|
|
465
|
+
float(status),
|
|
466
|
+
metric_type="gauge",
|
|
467
|
+
labels={"check_type": check_type},
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# Pass-through methods
|
|
471
|
+
def is_available(self) -> bool:
|
|
472
|
+
"""Check if prometheus_client is installed."""
|
|
473
|
+
return self._exporter.is_available()
|
|
474
|
+
|
|
475
|
+
def is_enabled(self) -> bool:
|
|
476
|
+
"""Check if Prometheus metrics are enabled and available."""
|
|
477
|
+
return self._exporter.is_enabled()
|
|
478
|
+
|
|
479
|
+
def start_server(
|
|
480
|
+
self,
|
|
481
|
+
port: Optional[int] = None,
|
|
482
|
+
host: Optional[str] = None,
|
|
483
|
+
) -> bool:
|
|
484
|
+
"""Start the HTTP server for /metrics endpoint."""
|
|
485
|
+
return self._exporter.start_server(port, host)
|
|
486
|
+
|
|
487
|
+
def get_config(self) -> Any:
|
|
488
|
+
"""Get the current configuration."""
|
|
489
|
+
return self._exporter.get_config()
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# =============================================================================
|
|
493
|
+
# Global Collector Singleton
|
|
494
|
+
# =============================================================================
|
|
495
|
+
|
|
496
|
+
_collector: Optional[MetricsPersistenceCollector] = None
|
|
497
|
+
_collector_lock = threading.Lock()
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def get_metrics_collector(
|
|
501
|
+
config: Optional[MetricsPersistenceConfig] = None,
|
|
502
|
+
) -> MetricsPersistenceCollector:
|
|
503
|
+
"""
|
|
504
|
+
Get the global metrics collector singleton.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
config: Optional configuration (only used on first call)
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
MetricsPersistenceCollector singleton instance
|
|
511
|
+
"""
|
|
512
|
+
global _collector
|
|
513
|
+
|
|
514
|
+
if _collector is None:
|
|
515
|
+
with _collector_lock:
|
|
516
|
+
if _collector is None:
|
|
517
|
+
if config is None:
|
|
518
|
+
# Use default disabled config
|
|
519
|
+
config = MetricsPersistenceConfig(enabled=False)
|
|
520
|
+
_collector = MetricsPersistenceCollector(config)
|
|
521
|
+
|
|
522
|
+
return _collector
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def reset_metrics_collector() -> None:
|
|
526
|
+
"""Reset the global collector (for testing)."""
|
|
527
|
+
global _collector
|
|
528
|
+
with _collector_lock:
|
|
529
|
+
if _collector is not None:
|
|
530
|
+
_collector.shutdown()
|
|
531
|
+
_collector = None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def initialize_metrics_persistence(
|
|
535
|
+
config: MetricsPersistenceConfig,
|
|
536
|
+
) -> Optional[MetricsPersistenceCollector]:
|
|
537
|
+
"""
|
|
538
|
+
Initialize metrics persistence with the given configuration.
|
|
539
|
+
|
|
540
|
+
Should be called during server startup to enable metrics persistence.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
config: Metrics persistence configuration
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
The initialized collector, or None if disabled
|
|
547
|
+
"""
|
|
548
|
+
global _collector
|
|
549
|
+
|
|
550
|
+
if not config.enabled:
|
|
551
|
+
logger.debug("Metrics persistence is disabled")
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
with _collector_lock:
|
|
555
|
+
if _collector is not None:
|
|
556
|
+
_collector.shutdown()
|
|
557
|
+
|
|
558
|
+
_collector = MetricsPersistenceCollector(config)
|
|
559
|
+
logger.info(
|
|
560
|
+
f"Initialized metrics persistence: "
|
|
561
|
+
f"storage={config.get_storage_path()}, "
|
|
562
|
+
f"bucket_interval={config.bucket_interval_seconds}s, "
|
|
563
|
+
f"flush_interval={config.flush_interval_seconds}s"
|
|
564
|
+
)
|
|
565
|
+
return _collector
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# =============================================================================
|
|
569
|
+
# Exports
|
|
570
|
+
# =============================================================================
|
|
571
|
+
|
|
572
|
+
__all__ = [
|
|
573
|
+
# Data structures
|
|
574
|
+
"MetricBucket",
|
|
575
|
+
"MetricDataPoint",
|
|
576
|
+
# Collector
|
|
577
|
+
"MetricsPersistenceCollector",
|
|
578
|
+
"get_metrics_collector",
|
|
579
|
+
"reset_metrics_collector",
|
|
580
|
+
"initialize_metrics_persistence",
|
|
581
|
+
# Prometheus integration
|
|
582
|
+
"PersistenceAwareExporter",
|
|
583
|
+
"create_persistence_aware_exporter",
|
|
584
|
+
]
|