Option C Tools
Loading...
Searching...
No Matches
metrics.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/metrics.py
4
5"""
6Purpose
7-------
8Optional Prometheus instrumentation for the ``oct-mcp`` server (Phase 5C).
9
10Exposes five metrics:
11
12* ``oct_mcp_tool_calls_total`` — Counter; labels ``tool``, ``decision``.
13* ``oct_mcp_tool_duration_seconds`` — Histogram; label ``tool``.
14* ``oct_mcp_rate_limit_hits_total`` — Counter; label ``tool``.
15* ``oct_mcp_redactions_total`` — Counter; label ``tool``.
16* ``oct_mcp_validation_failures_total`` — Counter; label ``tool``.
17
18All public methods are **no-ops** when ``prometheus_client`` is not
19installed, so the rest of the pipeline never needs to guard with
20``if metrics is not None``.
21
22Enabling metrics
23----------------
24Install the optional extra::
25
26 pip install 'oct[metrics]'
27
28Then start the server with ``--metrics-port``::
29
30 python -m oct.mcp --project-root /path/to/project --metrics-port 9090
31
32Diagnostics
33-----------
34Domain: MCP
35Levels:
36 L1 — errors: metrics server startup failures
37 L2 — lifecycle: metrics server started
38
39Contracts
40---------
41- This module never raises for the caller — all methods swallow errors
42 and degrade gracefully.
43- When ``prometheus_client`` is absent, ``PROMETHEUS_AVAILABLE`` is
44 ``False`` and :class:`McpMetrics` is a lightweight no-op stub.
45- The metrics HTTP server is started in a **daemon** background thread
46 so it does not prevent process exit.
47- :class:`McpMetrics` accepts an optional ``registry`` parameter
48 (``CollectorRegistry``). The default is the module-level
49 ``prometheus_client.REGISTRY`` so production ``/metrics`` works
50 without any caller change. Tests and multi-tenant callers pass a
51 private ``CollectorRegistry()`` to avoid ``Duplicated timeseries``
52 collisions across instances.
53"""
54
55from __future__ import annotations
56
57import threading
58
59try:
60 from prometheus_client import (
61 Counter,
62 Histogram,
63 REGISTRY,
64 start_http_server as _start_http,
65 )
66 from prometheus_client.registry import CollectorRegistry
67
68 PROMETHEUS_AVAILABLE: bool = True
69except ImportError: # pragma: no cover — only absent when oct[metrics] not installed
70 PROMETHEUS_AVAILABLE = False
71 REGISTRY = None # type: ignore[assignment]
72 CollectorRegistry = None # type: ignore[assignment,misc]
73
74try:
75 from oc_diagnostics import _dbg as _real_dbg
76
77 def _dbg(*args, **kwargs) -> None:
78 _real_dbg(*args, **kwargs)
79except ImportError: # pragma: no cover
80 def _dbg(*args, **kwargs) -> None:
81 return None
82
83
85 """Prometheus instrumentation for the oct-mcp pipeline.
86
87 All record methods are no-ops when ``prometheus_client`` is absent.
88 Instantiate once per server session and pass into :class:`ServerState`.
89 """
90
91 def __init__(self, registry: "CollectorRegistry | None" = None) -> None:
92 """Build the metric vectors.
93
94 Parameters
95 ----------
96 registry
97 Prometheus :class:`CollectorRegistry` to register metrics on.
98 ``None`` (default) uses the module-level global ``REGISTRY``,
99 which is what ``prometheus_client.start_http_server()`` exposes
100 on ``/metrics`` by default. Pass a private
101 ``CollectorRegistry()`` for tests or multi-tenant scenarios
102 that need isolation from other ``McpMetrics`` instances.
103 """
104 if not PROMETHEUS_AVAILABLE:
105 return
106
107 # Default to the global REGISTRY so production /metrics keeps
108 # working without any caller change. Tests pass their own
109 # CollectorRegistry to avoid `Duplicated timeseries` collisions.
110 self._registry = registry if registry is not None else REGISTRY
111
112 self._tool_calls: Counter = Counter(
113 "oct_mcp_tool_calls_total",
114 "Total MCP tool calls processed by oct-mcp",
115 ["tool", "decision"],
116 registry=self._registry,
117 )
118 self._tool_duration: Histogram = Histogram(
119 "oct_mcp_tool_duration_seconds",
120 "Wall-clock time from tool call receipt to response (seconds)",
121 ["tool"],
122 registry=self._registry,
123 )
124 self._rate_limit_hits: Counter = Counter(
125 "oct_mcp_rate_limit_hits_total",
126 "Tool calls rejected by the rate limiter",
127 ["tool"],
128 registry=self._registry,
129 )
130 self._redactions: Counter = Counter(
131 "oct_mcp_redactions_total",
132 "Redaction substitutions applied to tool output",
133 ["tool"],
134 registry=self._registry,
135 )
136 self._validation_failures: Counter = Counter(
137 "oct_mcp_validation_failures_total",
138 "Tool calls rejected by input validation",
139 ["tool"],
140 registry=self._registry,
141 )
142
143 # ------------------------------------------------------------------
144 # Public API
145 # ------------------------------------------------------------------
146
147 def start_server(self, port: int) -> None:
148 """Start the Prometheus ``/metrics`` HTTP server on *port*.
149
150 Runs in a **daemon** background thread so it does not block the
151 MCP event loop. Silently no-ops when ``prometheus_client`` is
152 absent.
153
154 Parameters
155 ----------
156 port
157 TCP port to bind. Must be in the range 1–65535.
158 """
159 if not PROMETHEUS_AVAILABLE:
160 return
161 func = "McpMetrics.start_server"
162 try:
163 # Pass the same registry the metrics were built on; otherwise
164 # an isolated-registry instance would expose nothing on /metrics
165 # because start_http_server defaults to prometheus_client.REGISTRY.
166 t = threading.Thread(
167 target=_start_http,
168 args=(port,),
169 kwargs={"registry": self._registry},
170 daemon=True,
171 )
172 t.start()
173 _dbg("MCP", func, f"Prometheus metrics server started on port {port}", 2)
174 except Exception as exc: # pragma: no cover
175 _dbg("MCP", func, f"SYSTEM_ERROR: failed to start metrics server: {exc}", 1)
176
178 self,
179 tool: str,
180 decision: str,
181 duration_s: float,
182 redactions: int,
183 ) -> None:
184 """Record a completed (or denied) tool call.
185
186 Parameters
187 ----------
188 tool
189 MCP tool name, e.g. ``"oct_lint"``.
190 decision
191 Pipeline decision: ``"allowed"``, ``"denied"``, or
192 ``"requires_confirmation"``.
193 duration_s
194 Wall-clock duration in seconds.
195 redactions
196 Number of redaction substitutions applied to the output.
197 """
198 if not PROMETHEUS_AVAILABLE:
199 return
200 try:
201 self._tool_calls.labels(tool=tool, decision=decision).inc()
202 self._tool_duration.labels(tool=tool).observe(duration_s)
203 if redactions > 0:
204 self._redactions.labels(tool=tool).inc(redactions)
205 except Exception: # pragma: no cover
206 pass
207
208 def record_rate_limit_hit(self, tool: str) -> None:
209 """Increment the rate-limit rejection counter for *tool*."""
210 if not PROMETHEUS_AVAILABLE:
211 return
212 try:
213 self._rate_limit_hits.labels(tool=tool).inc()
214 except Exception: # pragma: no cover
215 pass
216
217 def record_validation_failure(self, tool: str) -> None:
218 """Increment the validation-failure counter for *tool*."""
219 if not PROMETHEUS_AVAILABLE:
220 return
221 try:
222 self._validation_failures.labels(tool=tool).inc()
223 except Exception: # pragma: no cover
224 pass
None __init__(self, "CollectorRegistry | None" registry=None)
Definition metrics.py:91
None start_server(self, int port)
Definition metrics.py:147
None record_validation_failure(self, str tool)
Definition metrics.py:217
None record_rate_limit_hit(self, str tool)
Definition metrics.py:208
None record_call(self, str tool, str decision, float duration_s, int redactions)
Definition metrics.py:183
None _dbg(*args, **kwargs)
Definition metrics.py:77