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