Option C Tools
Loading...
Searching...
No Matches
test_mcp_metrics.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_mcp/test_mcp_metrics.py
4
5"""
6Purpose
7-------
8Phase 5C tests for :class:`oct.mcp.metrics.McpMetrics` (Prometheus
9instrumentation).
10
11Responsibilities
12----------------
13- Verify no-op behaviour when ``prometheus_client`` is not installed.
14- Verify ``record_call`` increments counters and records histogram.
15- Verify ``record_rate_limit_hit`` and ``record_validation_failure``
16 counters.
17- Verify ``start_server`` launches a background daemon thread.
18- Verify end-to-end metric recording through ``_run_tool``.
19
20Diagnostics
21-----------
22Domain: MCP-TESTS
23Levels:
24 L2 — test lifecycle
25 L3 — assertion details
26 L4 — deep tracing
27
28Contracts
29---------
30- Tests mock ``prometheus_client`` imports; no real metrics server is
31 started.
32"""
33
34from __future__ import annotations
35
36import json
37from pathlib import Path
38from unittest.mock import MagicMock, patch, call
39
40import pytest
41
42from oct.mcp.metrics import McpMetrics, PROMETHEUS_AVAILABLE
43
44
45# ---------------------------------------------------------------------------
46# Helpers
47# ---------------------------------------------------------------------------
48
50 try:
51 import prometheus_client # noqa: F401
52 return True
53 except ImportError:
54 return False
55
56
57def _isolated_metrics() -> McpMetrics:
58 """McpMetrics on a private CollectorRegistry — avoids
59 ``Duplicated timeseries`` collisions across tests when
60 ``prometheus_client`` is installed."""
61 if not PROMETHEUS_AVAILABLE:
62 return McpMetrics()
63 from prometheus_client import CollectorRegistry
64 return McpMetrics(registry=CollectorRegistry())
65
66
67# ---------------------------------------------------------------------------
68# Module-level constant
69# ---------------------------------------------------------------------------
70
73 assert isinstance(PROMETHEUS_AVAILABLE, bool)
74
76 assert PROMETHEUS_AVAILABLE == _is_prometheus_installed()
77
78
79# ---------------------------------------------------------------------------
80# No-op behavior when prometheus_client is absent
81# ---------------------------------------------------------------------------
82
84 """When prometheus_client is not installed, all methods should be no-ops."""
85
86 def _make_metrics_without_prometheus(self) -> McpMetrics:
87 """Return a McpMetrics instance pretending prometheus_client is absent."""
88 with patch("oct.mcp.metrics.PROMETHEUS_AVAILABLE", False):
89 m = McpMetrics.__new__(McpMetrics)
90 m.__init__()
91 return m
92
94 with patch("oct.mcp.metrics.PROMETHEUS_AVAILABLE", False):
95 metrics = McpMetrics()
96 metrics.record_call("oct_lint", "allowed", 0.05, 0) # must not raise
97
99 with patch("oct.mcp.metrics.PROMETHEUS_AVAILABLE", False):
100 metrics = McpMetrics()
101 metrics.record_rate_limit_hit("oct_lint")
102
104 with patch("oct.mcp.metrics.PROMETHEUS_AVAILABLE", False):
105 metrics = McpMetrics()
106 metrics.record_validation_failure("oct_lint")
107
109 with patch("oct.mcp.metrics.PROMETHEUS_AVAILABLE", False):
110 metrics = McpMetrics()
111 metrics.start_server(9090)
112
113
114# ---------------------------------------------------------------------------
115# Prometheus-backed behavior
116# ---------------------------------------------------------------------------
117
119 """Tests using mock Prometheus Counter / Histogram objects."""
120
121 def _make_metrics(self):
122 """Build McpMetrics with mocked prometheus_client objects."""
123 mock_counter_class = MagicMock()
124 mock_histogram_class = MagicMock()
125
126 # Counter() returns a mock with a .labels() chain
127 mock_counter_instance = MagicMock()
128 mock_counter_instance.labels.return_value = mock_counter_instance
129 mock_counter_class.return_value = mock_counter_instance
130
131 mock_histogram_instance = MagicMock()
132 mock_histogram_instance.labels.return_value = mock_histogram_instance
133 mock_histogram_class.return_value = mock_histogram_instance
134
135 with patch("oct.mcp.metrics.PROMETHEUS_AVAILABLE", True):
136 with patch("oct.mcp.metrics.Counter", mock_counter_class):
137 with patch("oct.mcp.metrics.Histogram", mock_histogram_class):
138 metrics = McpMetrics()
139
140 return metrics, mock_counter_instance, mock_histogram_instance
141
142 @pytest.mark.skipif(not _is_prometheus_installed(), reason="prometheus_client not installed")
144 metrics = _isolated_metrics()
145 # The counter should not raise; call it
146 metrics.record_call("oct_lint", "allowed", 0.1, 0)
147
148 @pytest.mark.skipif(not _is_prometheus_installed(), reason="prometheus_client not installed")
150 metrics = _isolated_metrics()
151 metrics.record_call("oct_lint", "allowed", 0.25, 0)
152
153 @pytest.mark.skipif(not _is_prometheus_installed(), reason="prometheus_client not installed")
155 metrics = _isolated_metrics()
156 metrics.record_rate_limit_hit("oct_lint")
157
158 @pytest.mark.skipif(not _is_prometheus_installed(), reason="prometheus_client not installed")
160 metrics = _isolated_metrics()
161 metrics.record_validation_failure("oct_lint")
162
165 pytest.skip("prometheus_client not installed")
166
167 started_threads = []
168
169 class MockThread:
170 def __init__(self, target, args, daemon, kwargs=None):
171 self.target = target
172 self.args = args
173 self.kwargs = kwargs or {}
174 self.daemon = daemon
175 self._started = False
176
177 def start(self):
178 self._started = True
179 started_threads.append(self)
180
181 with patch("oct.mcp.metrics.PROMETHEUS_AVAILABLE", True):
182 with patch("oct.mcp.metrics.threading.Thread", MockThread):
183 with patch("oct.mcp.metrics._start_http"):
184 metrics = _isolated_metrics()
185 metrics.start_server(9090)
186
187 assert len(started_threads) == 1
188 assert started_threads[0].daemon is True
189 # Confirm the registry kwarg is propagated to start_http_server.
190 assert "registry" in started_threads[0].kwargs
191 assert started_threads[0].kwargs["registry"] is metrics._registry
192
194 """start_server with PROMETHEUS_AVAILABLE=False must not start any thread."""
195 import threading
196 threads_before = threading.active_count()
197 with patch("oct.mcp.metrics.PROMETHEUS_AVAILABLE", False):
198 metrics = McpMetrics()
199 metrics.start_server(9090)
200 threads_after = threading.active_count()
201 assert threads_after == threads_before
202
204 """Verify record_call calls .labels(tool=..., decision=...) on counter."""
205 mock_counter = MagicMock()
206 mock_histogram = MagicMock()
207
208 # Bypass __init__ and inject mock attributes directly so we don't
209 # need prometheus_client installed at the module level.
210 with patch("oct.mcp.metrics.PROMETHEUS_AVAILABLE", True):
211 metrics = McpMetrics.__new__(McpMetrics)
212 metrics._tool_calls = mock_counter
213 metrics._tool_duration = mock_histogram
214 metrics._redactions = MagicMock()
215 metrics._rate_limit_hits = MagicMock()
216 metrics._validation_failures = MagicMock()
217 metrics.record_call("oct_lint", "allowed", 0.1, 0)
218
219 mock_counter.labels.assert_called_with(tool="oct_lint", decision="allowed")
220
222 """When redactions > 0, _redactions counter must be incremented."""
223 mock_redact_counter = MagicMock()
224
225 with patch("oct.mcp.metrics.PROMETHEUS_AVAILABLE", True):
226 metrics = McpMetrics.__new__(McpMetrics)
227 # Bypass __init__; manually set attributes
228 metrics._tool_calls = MagicMock()
229 metrics._tool_duration = MagicMock()
230 metrics._redactions = mock_redact_counter
231 metrics._rate_limit_hits = MagicMock()
232 metrics._validation_failures = MagicMock()
233 metrics.record_call("oct_lint", "allowed", 0.1, 3)
234
235 mock_redact_counter.labels.assert_called_with(tool="oct_lint")
236 mock_redact_counter.labels.return_value.inc.assert_called_with(3)
237
239 """When redactions == 0, _redactions counter must NOT be incremented."""
240 mock_redact_counter = MagicMock()
241
242 with patch("oct.mcp.metrics.PROMETHEUS_AVAILABLE", True):
243 metrics = McpMetrics.__new__(McpMetrics)
244 metrics._tool_calls = MagicMock()
245 metrics._tool_duration = MagicMock()
246 metrics._redactions = mock_redact_counter
247 metrics._rate_limit_hits = MagicMock()
248 metrics._validation_failures = MagicMock()
249 metrics.record_call("oct_lint", "allowed", 0.1, 0)
250
251 mock_redact_counter.labels.assert_not_called()
252
253
254# ---------------------------------------------------------------------------
255# E2E integration with _run_tool
256# ---------------------------------------------------------------------------
257
259 """Verify that _run_tool() calls the right metrics methods."""
260
261 def _make_state(self, tmp_path: Path, mock_metrics: McpMetrics):
262 from oct.mcp.audit import McpAuditLogger
263 from oct.mcp.config import McpConfig
264 from oct.mcp.executor import ToolExecutor
265 from oct.mcp.policy import McpPolicy
266 from oct.mcp.redactor import Redactor
267 from oct.mcp.safety import SafetyGate
268 from oct.mcp.sandbox import SandboxExecutor, SandboxResult
269 from oct.mcp.server import RateLimiter, ServerState
270
271 (tmp_path / "pyproject.toml").write_text(
272 '[project]\nname="test"\nversion="0.1"\n'
273 )
274 mock_sandbox = MagicMock(spec=SandboxExecutor)
275 mock_sandbox.run.return_value = SandboxResult(
276 exit_code=0, stdout="ok", stderr=""
277 )
278 config = McpConfig()
279 return ServerState(
280 project_root=tmp_path,
281 config=config,
282 policy=McpPolicy(profile="default", safe_mode=False),
283 executor=ToolExecutor(sandbox=mock_sandbox),
284 redactor=Redactor(),
285 audit_logger=McpAuditLogger(audit_log_path=tmp_path / "audit.log"),
286 rate_limiter=RateLimiter(limit_per_minute=30),
287 safety=SafetyGate(config),
288 metrics=mock_metrics,
289 session_id="metrics-test-session",
290 )
291
292 def test_run_tool_calls_record_call(self, tmp_path: Path):
293 from oct.mcp.tools import _run_tool
294 import oct.mcp.server as _server_module
295
296 mock_metrics = MagicMock(spec=McpMetrics)
297 state = self._make_state(tmp_path, mock_metrics)
298 _server_module._server_state = state
299 _run_tool("oct_lint", {})
300 mock_metrics.record_call.assert_called_once()
301
303 from oct.mcp.server import RateLimiter
304 from oct.mcp.tools import _run_tool
305 import oct.mcp.server as _server_module
306
307 mock_metrics = MagicMock(spec=McpMetrics)
308 state = self._make_state(tmp_path, mock_metrics)
309 # Replace rate limiter with one that's already exhausted
310 exhausted_limiter = MagicMock(spec=RateLimiter)
311 exhausted_limiter.consume.return_value = False
312 state.rate_limiter = exhausted_limiter
313 _server_module._server_state = state
314 _run_tool("oct_lint", {})
315 mock_metrics.record_rate_limit_hit.assert_called_once_with("oct_lint")
316
318 self, tmp_path: Path
319 ):
320 from oct.mcp.tools import _run_tool
321 import oct.mcp.server as _server_module
322
323 mock_metrics = MagicMock(spec=McpMetrics)
324 state = self._make_state(tmp_path, mock_metrics)
325 _server_module._server_state = state
326 # Path traversal triggers validation failure
327 _run_tool("oct_lint", {"paths": ["../../etc/passwd"]})
328 mock_metrics.record_validation_failure.assert_called_once_with("oct_lint")
_make_state(self, Path tmp_path, McpMetrics mock_metrics)
test_run_tool_rate_limited_calls_record_rate_limit_hit(self, Path tmp_path)
test_run_tool_validation_failure_calls_record_validation_failure(self, Path tmp_path)