foundry-mcp 0.3.3__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.
Files changed (135) hide show
  1. foundry_mcp/__init__.py +7 -0
  2. foundry_mcp/cli/__init__.py +80 -0
  3. foundry_mcp/cli/__main__.py +9 -0
  4. foundry_mcp/cli/agent.py +96 -0
  5. foundry_mcp/cli/commands/__init__.py +37 -0
  6. foundry_mcp/cli/commands/cache.py +137 -0
  7. foundry_mcp/cli/commands/dashboard.py +148 -0
  8. foundry_mcp/cli/commands/dev.py +446 -0
  9. foundry_mcp/cli/commands/journal.py +377 -0
  10. foundry_mcp/cli/commands/lifecycle.py +274 -0
  11. foundry_mcp/cli/commands/modify.py +824 -0
  12. foundry_mcp/cli/commands/plan.py +633 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +652 -0
  15. foundry_mcp/cli/commands/session.py +479 -0
  16. foundry_mcp/cli/commands/specs.py +856 -0
  17. foundry_mcp/cli/commands/tasks.py +807 -0
  18. foundry_mcp/cli/commands/testing.py +676 -0
  19. foundry_mcp/cli/commands/validate.py +982 -0
  20. foundry_mcp/cli/config.py +98 -0
  21. foundry_mcp/cli/context.py +259 -0
  22. foundry_mcp/cli/flags.py +266 -0
  23. foundry_mcp/cli/logging.py +212 -0
  24. foundry_mcp/cli/main.py +44 -0
  25. foundry_mcp/cli/output.py +122 -0
  26. foundry_mcp/cli/registry.py +110 -0
  27. foundry_mcp/cli/resilience.py +178 -0
  28. foundry_mcp/cli/transcript.py +217 -0
  29. foundry_mcp/config.py +850 -0
  30. foundry_mcp/core/__init__.py +144 -0
  31. foundry_mcp/core/ai_consultation.py +1636 -0
  32. foundry_mcp/core/cache.py +195 -0
  33. foundry_mcp/core/capabilities.py +446 -0
  34. foundry_mcp/core/concurrency.py +898 -0
  35. foundry_mcp/core/context.py +540 -0
  36. foundry_mcp/core/discovery.py +1603 -0
  37. foundry_mcp/core/error_collection.py +728 -0
  38. foundry_mcp/core/error_store.py +592 -0
  39. foundry_mcp/core/feature_flags.py +592 -0
  40. foundry_mcp/core/health.py +749 -0
  41. foundry_mcp/core/journal.py +694 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1350 -0
  44. foundry_mcp/core/llm_patterns.py +510 -0
  45. foundry_mcp/core/llm_provider.py +1569 -0
  46. foundry_mcp/core/logging_config.py +374 -0
  47. foundry_mcp/core/metrics_persistence.py +584 -0
  48. foundry_mcp/core/metrics_registry.py +327 -0
  49. foundry_mcp/core/metrics_store.py +641 -0
  50. foundry_mcp/core/modifications.py +224 -0
  51. foundry_mcp/core/naming.py +123 -0
  52. foundry_mcp/core/observability.py +1216 -0
  53. foundry_mcp/core/otel.py +452 -0
  54. foundry_mcp/core/otel_stubs.py +264 -0
  55. foundry_mcp/core/pagination.py +255 -0
  56. foundry_mcp/core/progress.py +317 -0
  57. foundry_mcp/core/prometheus.py +577 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +546 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
  61. foundry_mcp/core/prompts/plan_review.py +623 -0
  62. foundry_mcp/core/providers/__init__.py +225 -0
  63. foundry_mcp/core/providers/base.py +476 -0
  64. foundry_mcp/core/providers/claude.py +460 -0
  65. foundry_mcp/core/providers/codex.py +619 -0
  66. foundry_mcp/core/providers/cursor_agent.py +642 -0
  67. foundry_mcp/core/providers/detectors.py +488 -0
  68. foundry_mcp/core/providers/gemini.py +405 -0
  69. foundry_mcp/core/providers/opencode.py +616 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +302 -0
  71. foundry_mcp/core/providers/package-lock.json +24 -0
  72. foundry_mcp/core/providers/package.json +25 -0
  73. foundry_mcp/core/providers/registry.py +607 -0
  74. foundry_mcp/core/providers/test_provider.py +171 -0
  75. foundry_mcp/core/providers/validation.py +729 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/resilience.py +600 -0
  78. foundry_mcp/core/responses.py +934 -0
  79. foundry_mcp/core/review.py +366 -0
  80. foundry_mcp/core/security.py +438 -0
  81. foundry_mcp/core/spec.py +1650 -0
  82. foundry_mcp/core/task.py +1289 -0
  83. foundry_mcp/core/testing.py +450 -0
  84. foundry_mcp/core/validation.py +2081 -0
  85. foundry_mcp/dashboard/__init__.py +32 -0
  86. foundry_mcp/dashboard/app.py +119 -0
  87. foundry_mcp/dashboard/components/__init__.py +17 -0
  88. foundry_mcp/dashboard/components/cards.py +88 -0
  89. foundry_mcp/dashboard/components/charts.py +234 -0
  90. foundry_mcp/dashboard/components/filters.py +136 -0
  91. foundry_mcp/dashboard/components/tables.py +195 -0
  92. foundry_mcp/dashboard/data/__init__.py +11 -0
  93. foundry_mcp/dashboard/data/stores.py +433 -0
  94. foundry_mcp/dashboard/launcher.py +289 -0
  95. foundry_mcp/dashboard/views/__init__.py +12 -0
  96. foundry_mcp/dashboard/views/errors.py +217 -0
  97. foundry_mcp/dashboard/views/metrics.py +174 -0
  98. foundry_mcp/dashboard/views/overview.py +160 -0
  99. foundry_mcp/dashboard/views/providers.py +83 -0
  100. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  101. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  102. foundry_mcp/prompts/__init__.py +9 -0
  103. foundry_mcp/prompts/workflows.py +525 -0
  104. foundry_mcp/resources/__init__.py +9 -0
  105. foundry_mcp/resources/specs.py +591 -0
  106. foundry_mcp/schemas/__init__.py +38 -0
  107. foundry_mcp/schemas/sdd-spec-schema.json +386 -0
  108. foundry_mcp/server.py +164 -0
  109. foundry_mcp/tools/__init__.py +10 -0
  110. foundry_mcp/tools/unified/__init__.py +71 -0
  111. foundry_mcp/tools/unified/authoring.py +1487 -0
  112. foundry_mcp/tools/unified/context_helpers.py +98 -0
  113. foundry_mcp/tools/unified/documentation_helpers.py +198 -0
  114. foundry_mcp/tools/unified/environment.py +939 -0
  115. foundry_mcp/tools/unified/error.py +462 -0
  116. foundry_mcp/tools/unified/health.py +225 -0
  117. foundry_mcp/tools/unified/journal.py +841 -0
  118. foundry_mcp/tools/unified/lifecycle.py +632 -0
  119. foundry_mcp/tools/unified/metrics.py +777 -0
  120. foundry_mcp/tools/unified/plan.py +745 -0
  121. foundry_mcp/tools/unified/pr.py +294 -0
  122. foundry_mcp/tools/unified/provider.py +629 -0
  123. foundry_mcp/tools/unified/review.py +685 -0
  124. foundry_mcp/tools/unified/review_helpers.py +299 -0
  125. foundry_mcp/tools/unified/router.py +102 -0
  126. foundry_mcp/tools/unified/server.py +580 -0
  127. foundry_mcp/tools/unified/spec.py +808 -0
  128. foundry_mcp/tools/unified/task.py +2202 -0
  129. foundry_mcp/tools/unified/test.py +370 -0
  130. foundry_mcp/tools/unified/verification.py +520 -0
  131. foundry_mcp-0.3.3.dist-info/METADATA +337 -0
  132. foundry_mcp-0.3.3.dist-info/RECORD +135 -0
  133. foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
  134. foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
  135. foundry_mcp-0.3.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,577 @@
1
+ """Prometheus metrics integration with graceful degradation.
2
+
3
+ This module provides Prometheus metrics integration that gracefully falls back
4
+ to no-op operations when the optional prometheus_client dependency is not installed.
5
+
6
+ Usage:
7
+ from foundry_mcp.core.prometheus import get_prometheus_exporter
8
+
9
+ exporter = get_prometheus_exporter()
10
+ exporter.record_tool_invocation("list_specs", success=True, duration_ms=45.2)
11
+
12
+ # Optionally start HTTP server for /metrics endpoint
13
+ exporter.start_server(port=9090)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import threading
20
+ import time
21
+ from dataclasses import dataclass
22
+ from typing import Any, Callable, Optional, TypeVar
23
+
24
+ # Try to import prometheus_client
25
+ try:
26
+ from prometheus_client import (
27
+ REGISTRY,
28
+ Counter,
29
+ Gauge,
30
+ Histogram,
31
+ start_http_server,
32
+ )
33
+
34
+ _PROMETHEUS_AVAILABLE = True
35
+ except ImportError:
36
+ _PROMETHEUS_AVAILABLE = False
37
+
38
+ # Placeholders so type checkers don't complain.
39
+ Counter: Any = None
40
+ Gauge: Any = None
41
+ Histogram: Any = None
42
+ REGISTRY: Any = None
43
+ start_http_server: Any = None
44
+
45
+ F = TypeVar("F", bound=Callable[..., Any])
46
+
47
+
48
+ # =============================================================================
49
+ # Configuration
50
+ # =============================================================================
51
+
52
+
53
+ @dataclass
54
+ class PrometheusConfig:
55
+ """Configuration for Prometheus metrics.
56
+
57
+ Attributes:
58
+ enabled: Whether Prometheus metrics are enabled
59
+ port: HTTP server port for /metrics endpoint (0 = no server)
60
+ host: HTTP server host
61
+ namespace: Metric namespace prefix
62
+ """
63
+
64
+ enabled: bool = False
65
+ port: int = 0 # 0 means don't start HTTP server
66
+ host: str = "0.0.0.0"
67
+ namespace: str = "foundry_mcp"
68
+
69
+ @classmethod
70
+ def from_env_and_config(
71
+ cls,
72
+ config: Optional[dict[str, Any]] = None,
73
+ ) -> "PrometheusConfig":
74
+ """Load configuration from environment variables and optional config dict.
75
+
76
+ Environment variables take precedence over config dict values.
77
+
78
+ Env vars:
79
+ PROMETHEUS_ENABLED: "true" or "1" to enable
80
+ PROMETHEUS_PORT: HTTP server port (0 = no server)
81
+ PROMETHEUS_HOST: HTTP server host
82
+ PROMETHEUS_NAMESPACE: Metric namespace
83
+
84
+ Args:
85
+ config: Optional dict with config values (typically from TOML)
86
+
87
+ Returns:
88
+ PrometheusConfig instance
89
+ """
90
+ config = config or {}
91
+
92
+ # Parse enabled from env or config
93
+ env_enabled = os.environ.get("PROMETHEUS_ENABLED", "").lower()
94
+ if env_enabled:
95
+ enabled = env_enabled in ("true", "1", "yes")
96
+ else:
97
+ enabled = config.get("enabled", False)
98
+
99
+ # Parse port
100
+ port_str = os.environ.get("PROMETHEUS_PORT")
101
+ if port_str:
102
+ try:
103
+ port = int(port_str)
104
+ except ValueError:
105
+ port = 0
106
+ else:
107
+ port = config.get("port", 0)
108
+
109
+ # Parse host
110
+ host = os.environ.get(
111
+ "PROMETHEUS_HOST",
112
+ config.get("host", "0.0.0.0"),
113
+ )
114
+
115
+ # Parse namespace
116
+ namespace = os.environ.get(
117
+ "PROMETHEUS_NAMESPACE",
118
+ config.get("namespace", "foundry_mcp"),
119
+ )
120
+
121
+ return cls(
122
+ enabled=enabled,
123
+ port=port,
124
+ host=host,
125
+ namespace=namespace,
126
+ )
127
+
128
+
129
+ # =============================================================================
130
+ # Prometheus Exporter
131
+ # =============================================================================
132
+
133
+
134
+ class PrometheusExporter:
135
+ """Prometheus metrics exporter with graceful degradation.
136
+
137
+ When prometheus_client is not installed or metrics are disabled,
138
+ all methods become no-ops that silently do nothing.
139
+ """
140
+
141
+ def __init__(self, config: Optional[PrometheusConfig] = None) -> None:
142
+ """Initialize the exporter.
143
+
144
+ Args:
145
+ config: Prometheus configuration. If None, loads from env/defaults.
146
+ """
147
+ self._config = config or PrometheusConfig.from_env_and_config()
148
+ self._initialized = False
149
+ self._server_started = False
150
+ self._lock = threading.Lock()
151
+
152
+ # Metric instances (set during initialization)
153
+ self._tool_invocations: Any = None
154
+ self._tool_duration: Any = None
155
+ self._tool_errors: Any = None
156
+ self._resource_access: Any = None
157
+ self._active_operations: Any = None
158
+
159
+ # Manifest/discovery metrics
160
+ self._manifest_tokens: Any = None
161
+ self._manifest_tool_count: Any = None
162
+ self._feature_flag_state: Any = None
163
+
164
+ # Health check metrics
165
+ self._health_status: Any = None
166
+ self._dependency_health: Any = None
167
+ self._health_check_duration: Any = None
168
+
169
+ # Auto-initialize if enabled
170
+ if self.is_enabled():
171
+ self._initialize_metrics()
172
+
173
+ def is_available(self) -> bool:
174
+ """Check if prometheus_client is installed."""
175
+ return _PROMETHEUS_AVAILABLE
176
+
177
+ def is_enabled(self) -> bool:
178
+ """Check if Prometheus metrics are enabled and available."""
179
+ return self._config.enabled and _PROMETHEUS_AVAILABLE
180
+
181
+ def _initialize_metrics(self) -> None:
182
+ """Initialize Prometheus metric instances."""
183
+ if self._initialized or not self.is_enabled():
184
+ return
185
+
186
+ with self._lock:
187
+ if self._initialized:
188
+ return
189
+
190
+ ns = self._config.namespace
191
+
192
+ # Tool invocation counter
193
+ self._tool_invocations = Counter(
194
+ f"{ns}_tool_invocations_total",
195
+ "Total number of tool invocations",
196
+ ["tool", "status"],
197
+ )
198
+
199
+ # Tool duration histogram
200
+ self._tool_duration = Histogram(
201
+ f"{ns}_tool_duration_seconds",
202
+ "Tool execution duration in seconds",
203
+ ["tool"],
204
+ buckets=(0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
205
+ )
206
+
207
+ # Tool error counter
208
+ self._tool_errors = Counter(
209
+ f"{ns}_tool_errors_total",
210
+ "Total number of tool errors",
211
+ ["tool", "error_type"],
212
+ )
213
+
214
+ # Resource access counter
215
+ self._resource_access = Counter(
216
+ f"{ns}_resource_access_total",
217
+ "Total number of resource accesses",
218
+ ["resource_type", "action"],
219
+ )
220
+
221
+ # Active operations gauge
222
+ self._active_operations = Gauge(
223
+ f"{ns}_active_operations",
224
+ "Number of currently active operations",
225
+ ["operation_type"],
226
+ )
227
+
228
+ # Manifest/discovery gauges
229
+ self._manifest_tokens = Gauge(
230
+ f"{ns}_manifest_tokens",
231
+ "Estimated token count for the advertised tool manifest",
232
+ ["manifest"], # unified|legacy
233
+ )
234
+ self._manifest_tool_count = Gauge(
235
+ f"{ns}_manifest_tool_count",
236
+ "Tool count for the advertised tool manifest",
237
+ ["manifest"], # unified|legacy
238
+ )
239
+ self._feature_flag_state = Gauge(
240
+ f"{ns}_feature_flag_state",
241
+ "Feature flag state (1=enabled, 0=disabled)",
242
+ ["flag"],
243
+ )
244
+
245
+ # Health check metrics
246
+ self._health_status = Gauge(
247
+ f"{ns}_health_status",
248
+ "Current health status (0=unhealthy, 1=degraded, 2=healthy)",
249
+ ["check_type"], # liveness, readiness, health
250
+ )
251
+
252
+ self._dependency_health = Gauge(
253
+ f"{ns}_dependency_health",
254
+ "Dependency health status (0=unhealthy, 1=healthy)",
255
+ ["dependency"],
256
+ )
257
+
258
+ self._health_check_duration = Histogram(
259
+ f"{ns}_health_check_duration_seconds",
260
+ "Health check duration in seconds",
261
+ ["check_type"],
262
+ buckets=(0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0),
263
+ )
264
+
265
+ self._initialized = True
266
+
267
+ def start_server(
268
+ self, port: Optional[int] = None, host: Optional[str] = None
269
+ ) -> bool:
270
+ """Start the HTTP server for /metrics endpoint.
271
+
272
+ Args:
273
+ port: Override port from config
274
+ host: Override host from config
275
+
276
+ Returns:
277
+ True if server started, False if already running or not enabled
278
+ """
279
+ if not self.is_enabled():
280
+ return False
281
+
282
+ if self._server_started:
283
+ return False
284
+
285
+ with self._lock:
286
+ if self._server_started:
287
+ return False
288
+
289
+ actual_port = port or self._config.port
290
+ if actual_port <= 0:
291
+ return False
292
+
293
+ actual_host = host or self._config.host
294
+
295
+ try:
296
+ start_http_server(actual_port, addr=actual_host)
297
+ self._server_started = True
298
+ return True
299
+ except Exception:
300
+ return False
301
+
302
+ def record_tool_invocation(
303
+ self,
304
+ tool_name: str,
305
+ *,
306
+ success: bool = True,
307
+ duration_ms: Optional[float] = None,
308
+ ) -> None:
309
+ """Record a tool invocation.
310
+
311
+ Args:
312
+ tool_name: Name of the tool
313
+ success: Whether the invocation was successful
314
+ duration_ms: Duration in milliseconds (optional)
315
+ """
316
+ if not self.is_enabled():
317
+ return
318
+
319
+ status = "success" if success else "error"
320
+ self._tool_invocations.labels(tool=tool_name, status=status).inc()
321
+
322
+ if duration_ms is not None:
323
+ # Convert ms to seconds for Prometheus conventions
324
+ self._tool_duration.labels(tool=tool_name).observe(duration_ms / 1000.0)
325
+
326
+ def record_tool_start(self, tool_name: str) -> None:
327
+ """Record tool execution start (increment active operations).
328
+
329
+ Args:
330
+ tool_name: Name of the tool
331
+ """
332
+ if not self.is_enabled():
333
+ return
334
+
335
+ self._active_operations.labels(operation_type=f"tool:{tool_name}").inc()
336
+
337
+ def record_tool_end(self, tool_name: str) -> None:
338
+ """Record tool execution end (decrement active operations).
339
+
340
+ Args:
341
+ tool_name: Name of the tool
342
+ """
343
+ if not self.is_enabled():
344
+ return
345
+
346
+ self._active_operations.labels(operation_type=f"tool:{tool_name}").dec()
347
+
348
+ def record_resource_access(
349
+ self,
350
+ resource_type: str,
351
+ action: str = "read",
352
+ ) -> None:
353
+ """Record a resource access.
354
+
355
+ Args:
356
+ resource_type: Type of resource (e.g., "spec", "task", "journal")
357
+ action: Action performed (e.g., "read", "write", "delete")
358
+ """
359
+ if not self.is_enabled():
360
+ return
361
+
362
+ self._resource_access.labels(resource_type=resource_type, action=action).inc()
363
+
364
+ def record_error(
365
+ self,
366
+ tool_name: str,
367
+ error_type: str = "unknown",
368
+ ) -> None:
369
+ """Record a tool error.
370
+
371
+ Args:
372
+ tool_name: Name of the tool
373
+ error_type: Type/category of error
374
+ """
375
+ if not self.is_enabled():
376
+ return
377
+
378
+ self._tool_errors.labels(tool=tool_name, error_type=error_type).inc()
379
+
380
+ # -------------------------------------------------------------------------
381
+ # Manifest/Discovery Metrics
382
+ # -------------------------------------------------------------------------
383
+
384
+ def record_manifest_snapshot(
385
+ self,
386
+ *,
387
+ manifest: str,
388
+ tokens: int,
389
+ tool_count: int,
390
+ ) -> None:
391
+ """Record a manifest snapshot (token count + tool count)."""
392
+ if not self.is_enabled():
393
+ return
394
+
395
+ manifest_label = manifest or "unknown"
396
+ self._manifest_tokens.labels(manifest=manifest_label).set(int(tokens))
397
+ self._manifest_tool_count.labels(manifest=manifest_label).set(int(tool_count))
398
+
399
+ def record_feature_flag_state(self, flag: str, enabled: bool) -> None:
400
+ """Record feature flag enabled/disabled state."""
401
+ if not self.is_enabled():
402
+ return
403
+
404
+ self._feature_flag_state.labels(flag=flag).set(1 if enabled else 0)
405
+
406
+ # -------------------------------------------------------------------------
407
+ # Health Check Metrics
408
+ # -------------------------------------------------------------------------
409
+
410
+ def record_health_check(
411
+ self,
412
+ check_type: str,
413
+ status: int,
414
+ duration_seconds: Optional[float] = None,
415
+ ) -> None:
416
+ """Record a health check result.
417
+
418
+ Args:
419
+ check_type: Type of check (liveness, readiness, health)
420
+ status: Health status (0=unhealthy, 1=degraded, 2=healthy)
421
+ duration_seconds: Optional duration of the check in seconds
422
+ """
423
+ if not self.is_enabled():
424
+ return
425
+
426
+ self._health_status.labels(check_type=check_type).set(status)
427
+
428
+ if duration_seconds is not None:
429
+ self._health_check_duration.labels(check_type=check_type).observe(
430
+ duration_seconds
431
+ )
432
+
433
+ def record_dependency_health(
434
+ self,
435
+ dependency: str,
436
+ healthy: bool,
437
+ ) -> None:
438
+ """Record dependency health status.
439
+
440
+ Args:
441
+ dependency: Name of the dependency (e.g., specs_dir, otel, prometheus)
442
+ healthy: Whether the dependency is healthy
443
+ """
444
+ if not self.is_enabled():
445
+ return
446
+
447
+ self._dependency_health.labels(dependency=dependency).set(1 if healthy else 0)
448
+
449
+ def record_health_check_batch(
450
+ self,
451
+ check_type: str,
452
+ status: int,
453
+ dependencies: dict[str, bool],
454
+ duration_seconds: Optional[float] = None,
455
+ ) -> None:
456
+ """Record a complete health check with all dependencies.
457
+
458
+ Convenience method to record overall status and all dependency statuses.
459
+
460
+ Args:
461
+ check_type: Type of check (liveness, readiness, health)
462
+ status: Health status (0=unhealthy, 1=degraded, 2=healthy)
463
+ dependencies: Dict mapping dependency name to healthy status
464
+ duration_seconds: Optional duration of the check in seconds
465
+ """
466
+ if not self.is_enabled():
467
+ return
468
+
469
+ # Record overall status
470
+ self.record_health_check(check_type, status, duration_seconds)
471
+
472
+ # Record each dependency
473
+ for dep_name, is_healthy in dependencies.items():
474
+ self.record_dependency_health(dep_name, is_healthy)
475
+
476
+ def get_config(self) -> PrometheusConfig:
477
+ """Get the current configuration."""
478
+ return self._config
479
+
480
+
481
+ # =============================================================================
482
+ # Singleton Instance
483
+ # =============================================================================
484
+
485
+ _exporter: Optional[PrometheusExporter] = None
486
+ _exporter_lock = threading.Lock()
487
+
488
+
489
+ def get_prometheus_exporter(
490
+ config: Optional[PrometheusConfig] = None,
491
+ ) -> PrometheusExporter:
492
+ """Get the singleton Prometheus exporter instance.
493
+
494
+ On first call, initializes with provided config or defaults.
495
+ Subsequent calls return the same instance (config parameter ignored).
496
+
497
+ Args:
498
+ config: Optional configuration (only used on first call)
499
+
500
+ Returns:
501
+ PrometheusExporter singleton instance
502
+ """
503
+ global _exporter
504
+
505
+ if _exporter is None:
506
+ with _exporter_lock:
507
+ if _exporter is None:
508
+ _exporter = PrometheusExporter(config)
509
+
510
+ return _exporter
511
+
512
+
513
+ def reset_exporter() -> None:
514
+ """Reset the singleton exporter (mainly for testing)."""
515
+ global _exporter
516
+ with _exporter_lock:
517
+ _exporter = None
518
+
519
+
520
+ # =============================================================================
521
+ # Context Manager for Timing
522
+ # =============================================================================
523
+
524
+
525
+ class timed_operation:
526
+ """Context manager for timing tool operations.
527
+
528
+ Usage:
529
+ with timed_operation("my_tool") as timer:
530
+ # do work
531
+ pass
532
+ # Automatically records duration
533
+ """
534
+
535
+ def __init__(
536
+ self, tool_name: str, exporter: Optional[PrometheusExporter] = None
537
+ ) -> None:
538
+ self.tool_name = tool_name
539
+ self.exporter = exporter or get_prometheus_exporter()
540
+ self.start_time: Optional[float] = None
541
+ self.success = True
542
+
543
+ def __enter__(self) -> "timed_operation":
544
+ self.start_time = time.perf_counter()
545
+ self.exporter.record_tool_start(self.tool_name)
546
+ return self
547
+
548
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
549
+ duration_ms = (time.perf_counter() - (self.start_time or 0)) * 1000
550
+ self.success = exc_type is None
551
+
552
+ self.exporter.record_tool_end(self.tool_name)
553
+ self.exporter.record_tool_invocation(
554
+ self.tool_name,
555
+ success=self.success,
556
+ duration_ms=duration_ms,
557
+ )
558
+
559
+ if exc_type is not None:
560
+ error_type = exc_type.__name__ if exc_type else "unknown"
561
+ self.exporter.record_error(self.tool_name, error_type)
562
+
563
+
564
+ # =============================================================================
565
+ # Exports
566
+ # =============================================================================
567
+
568
+ __all__ = [
569
+ # Configuration
570
+ "PrometheusConfig",
571
+ # Exporter
572
+ "PrometheusExporter",
573
+ "get_prometheus_exporter",
574
+ "reset_exporter",
575
+ # Context manager
576
+ "timed_operation",
577
+ ]