Skip to content

Commit fcb1585

Browse files
Add OpenTelemetry tracing and metrics for MCP tool calls (#24)
* Add OpenTelemetry tracing and metrics for MCP tool calls * Align OTLP export with Perforce gRPC collector defaults * Allow protocol selection
1 parent 08dccfd commit fcb1585

12 files changed

Lines changed: 935 additions & 10 deletions

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ RUN chmod +x ./perfecto-mcp && \
4343
USER perfecto-mcp
4444

4545
ENV MCP_DOCKER=true
46+
# Telemetry defaults to the Perforce gRPC collector. Override the destination
47+
# with OTEL_EXPORTER_OTLP_ENDPOINT, or set OTEL_SDK_DISABLED=true to turn it off.
4648

4749
# Command to run the application
4850
ENTRYPOINT ["./perfecto-mcp"]

build.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ def run_pyinstaller(name: str, icon: str):
111111
'--clean',
112112
'--noconfirm',
113113
*[f'--hidden-import={module}' for module in hiddenimports],
114+
'--hidden-import=opentelemetry.sdk.trace',
115+
'--hidden-import=opentelemetry.sdk.trace.export',
116+
'--hidden-import=opentelemetry.sdk.resources',
117+
'--hidden-import=opentelemetry.sdk.metrics',
118+
'--hidden-import=opentelemetry.sdk.metrics.export',
119+
'--hidden-import=opentelemetry.exporter.otlp.proto.grpc.trace_exporter',
120+
'--hidden-import=opentelemetry.exporter.otlp.proto.grpc.metric_exporter',
121+
'--hidden-import=opentelemetry.exporter.otlp.proto.http.trace_exporter',
122+
'--hidden-import=opentelemetry.exporter.otlp.proto.http.metric_exporter',
123+
'--hidden-import=opentelemetry.propagate',
124+
'--collect-submodules=opentelemetry',
125+
'--collect-all=grpc',
114126
])
115127

116128

main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from config.token import PerfectoToken, PerfectoTokenError
1313
from config.version import __version__, __executable__, __bundle__, __uvx__, get_version
1414
from server import register_tools
15+
from telemetry import init_telemetry
1516

1617
PERFECTO_SECURITY_TOKEN_FILE_NAME = "perfecto-security-token.txt"
1718
PERFECTO_SECURITY_TOKEN_FILE_PATH = os.getenv(SECURITY_TOKEN_FILE_ENV_NAME)
@@ -63,6 +64,7 @@ def get_token() -> PerfectoToken:
6364

6465

6566
def run(log_level: str = "CRITICAL"):
67+
init_telemetry("perfecto-mcp", __version__)
6668
token = get_token()
6769

6870
instructions = """

pyproject.toml

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ requires-python = ">=3.11"
77
dependencies = [
88
"httpx[http2]>=0.28.1",
99
"mcp[cli]>=1.17.0",
10+
"opentelemetry-api>=1.20.0",
11+
"opentelemetry-sdk>=1.20.0",
12+
"opentelemetry-exporter-otlp>=1.20.0",
1013
"pyinstaller>=6.0.0",
1114
"pydantic>=2.11.7",
1215
"pydantic-core>=2.33.2",
@@ -18,17 +21,20 @@ dependencies = [
1821
perfecto-mcp = "main:main"
1922

2023
[tool.setuptools]
21-
py-modules = ["main", "server"]
24+
py-modules = ["main", "server", "telemetry"]
2225
include-package-data = true
2326

27+
[dependency-groups]
28+
dev = [
29+
"pytest>=9.0.2",
30+
]
31+
2432
[tool.setuptools.packages.find]
2533
where = ["."]
2634
include = ["tools", "config", "models", "formatters", "resources"]
2735

2836
[tool.setuptools.package-data]
2937
"resources" = ["*.png"]
3038

31-
[dependency-groups]
32-
dev = [
33-
"pytest>=9.0.2",
34-
]
39+
[tool.pytest.ini_options]
40+
pythonpath = ["."]

telemetry.py

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
"""
2+
Copyright 2025 Perforce Software, Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
"""
16+
import logging
17+
import os
18+
import time
19+
from typing import Any, Awaitable, Callable
20+
21+
import httpx
22+
23+
logger = logging.getLogger(__name__)
24+
25+
try:
26+
from opentelemetry import metrics, trace # noqa: F401 — must be module-level for patching
27+
_OTEL_API_AVAILABLE = True
28+
except ImportError:
29+
trace = None # type: ignore[assignment]
30+
metrics = None # type: ignore[assignment]
31+
_OTEL_API_AVAILABLE = False
32+
33+
_call_counter = None
34+
_duration_histogram = None
35+
36+
DEFAULT_OTLP_ENDPOINT = "https://grpc.public.prd.shared.perforce.com"
37+
DEFAULT_OTLP_PROTOCOL = "grpc"
38+
39+
40+
def _get_otlp_protocol() -> str:
41+
return os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", DEFAULT_OTLP_PROTOCOL)
42+
43+
44+
def _create_trace_exporter():
45+
if _get_otlp_protocol() == "grpc":
46+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
47+
else:
48+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
49+
return OTLPSpanExporter()
50+
51+
52+
def _create_metric_exporter():
53+
if _get_otlp_protocol() == "grpc":
54+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
55+
else:
56+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
57+
return OTLPMetricExporter()
58+
59+
60+
def init_telemetry(service_name: str, service_version: str) -> None:
61+
global _call_counter, _duration_histogram
62+
63+
if not _OTEL_API_AVAILABLE:
64+
return
65+
if os.getenv("OTEL_SDK_DISABLED", "").lower() == "true":
66+
return
67+
try:
68+
# Lazy SDK imports: defer heavy setup until init_telemetry() and tolerate a
69+
# missing SDK (ImportError) without breaking module import or startup.
70+
from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_VERSION, Resource
71+
from opentelemetry.sdk.trace import TracerProvider
72+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
73+
74+
resource = Resource.create({
75+
SERVICE_NAME: service_name,
76+
SERVICE_VERSION: service_version,
77+
})
78+
provider = TracerProvider(resource=resource)
79+
80+
# Default export destination/protocol for shipped releases (gRPC + Perforce
81+
# collector). PAG or local dev may override via OTEL_EXPORTER_OTLP_ENDPOINT
82+
# and OTEL_EXPORTER_OTLP_PROTOCOL; OTEL_SDK_DISABLED=true disables telemetry.
83+
if not os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"):
84+
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = DEFAULT_OTLP_ENDPOINT
85+
86+
try:
87+
provider.add_span_processor(BatchSpanProcessor(_create_trace_exporter()))
88+
except Exception:
89+
logger.debug("OTLP trace exporter setup failed", exc_info=True)
90+
91+
trace.set_tracer_provider(provider)
92+
logger.debug("OTel TracerProvider initialised (service=%s, version=%s)", service_name, service_version)
93+
94+
try:
95+
from opentelemetry.sdk.metrics import MeterProvider
96+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
97+
98+
readers = []
99+
try:
100+
readers.append(PeriodicExportingMetricReader(_create_metric_exporter()))
101+
except Exception:
102+
logger.debug("OTLP metric exporter setup failed", exc_info=True)
103+
104+
meter_provider = MeterProvider(resource=resource, metric_readers=readers)
105+
metrics.set_meter_provider(meter_provider)
106+
107+
meter = metrics.get_meter("perfecto-mcp")
108+
_call_counter = meter.create_counter(
109+
"mcp.tool.calls",
110+
unit="{call}",
111+
description="Number of MCP tool calls",
112+
)
113+
_duration_histogram = meter.create_histogram(
114+
"mcp.tool.duration",
115+
unit="s",
116+
description="MCP tool call duration in seconds",
117+
)
118+
logger.debug("OTel MeterProvider initialised")
119+
except ImportError:
120+
pass
121+
except Exception:
122+
logger.debug("OTel metrics init failed", exc_info=True)
123+
124+
except ImportError:
125+
pass
126+
except Exception:
127+
logger.debug("OTel init failed; continuing without tracing", exc_info=True)
128+
129+
130+
def _get_meta(ctx: Any) -> dict:
131+
try:
132+
return ctx.request_context.request.params.meta or {}
133+
except Exception:
134+
return {}
135+
136+
137+
def _extract_trace_context(meta: dict):
138+
if not meta:
139+
return None
140+
try:
141+
from opentelemetry.propagate import extract
142+
carrier = {}
143+
if "traceparent" in meta:
144+
carrier["traceparent"] = meta["traceparent"]
145+
if "tracestate" in meta:
146+
carrier["tracestate"] = meta["tracestate"]
147+
return extract(carrier) if carrier else None
148+
except Exception:
149+
return None
150+
151+
152+
def _get_client_info(ctx: Any):
153+
try:
154+
info = ctx.request_context.session.client_params.clientInfo
155+
return info.name, info.version
156+
except Exception:
157+
return None, None
158+
159+
160+
def _get_session_id(ctx: Any) -> str | None:
161+
try:
162+
session_id = ctx.session_id
163+
return str(session_id) if session_id is not None else None
164+
except Exception:
165+
return None
166+
167+
168+
def _record_span_error(span: Any, error_type: str) -> None:
169+
try:
170+
span.set_attribute("error.type", error_type)
171+
except Exception:
172+
pass
173+
try:
174+
from opentelemetry.trace import Status, StatusCode
175+
span.set_status(Status(StatusCode.ERROR))
176+
except Exception:
177+
pass
178+
179+
180+
def _http_status_to_error_type(status_code: int) -> str:
181+
if status_code in (401, 403):
182+
return "auth_failed"
183+
if status_code == 404:
184+
return "not_found"
185+
if status_code == 429:
186+
return "rate_limited"
187+
if status_code >= 500:
188+
return "server_error"
189+
return f"http_{status_code}"
190+
191+
192+
def _record_metrics(tool_name: str, action: str, elapsed: float, error_type: str | None) -> None:
193+
attrs: dict[str, str] = {"gen_ai.tool.name": tool_name, "mcp.tool.action": action}
194+
if error_type is not None:
195+
attrs["error.type"] = error_type
196+
try:
197+
if _call_counter is not None:
198+
_call_counter.add(1, attrs)
199+
if _duration_histogram is not None:
200+
_duration_histogram.record(elapsed, attrs)
201+
except Exception:
202+
pass
203+
204+
205+
async def run_tool(
206+
tool_name: str,
207+
action: str,
208+
ctx: Any,
209+
dispatch: Callable[[], Awaitable[Any]],
210+
) -> Any:
211+
if trace is None:
212+
return await dispatch()
213+
214+
try:
215+
meta = _get_meta(ctx)
216+
parent_ctx = _extract_trace_context(meta)
217+
tracer = trace.get_tracer("perfecto-mcp")
218+
span_cm = tracer.start_as_current_span(
219+
f"tools/call {tool_name}",
220+
context=parent_ctx,
221+
kind=trace.SpanKind.SERVER,
222+
record_exception=False,
223+
set_status_on_exception=False,
224+
)
225+
except Exception:
226+
return await dispatch()
227+
228+
with span_cm as span:
229+
try:
230+
span.set_attribute("mcp.method.name", "tools/call")
231+
span.set_attribute("gen_ai.tool.name", tool_name)
232+
span.set_attribute("gen_ai.operation.name", "execute_tool")
233+
span.set_attribute("mcp.tool.action", action)
234+
client_name, client_version = _get_client_info(ctx)
235+
if client_name is not None:
236+
span.set_attribute("user_agent.name", client_name)
237+
if client_version is not None:
238+
span.set_attribute("user_agent.version", client_version)
239+
session_id = _get_session_id(ctx)
240+
if session_id is not None:
241+
span.set_attribute("mcp.session.id", session_id)
242+
except Exception:
243+
pass
244+
245+
start = time.perf_counter()
246+
error_type: str | None = None
247+
result = None
248+
try:
249+
result = await dispatch()
250+
except httpx.TimeoutException:
251+
error_type = "timeout"
252+
_record_span_error(span, error_type)
253+
raise
254+
except httpx.HTTPStatusError as e:
255+
error_type = _http_status_to_error_type(e.response.status_code)
256+
_record_span_error(span, error_type)
257+
raise
258+
except Exception:
259+
error_type = "tool_error"
260+
_record_span_error(span, error_type)
261+
raise
262+
finally:
263+
elapsed = time.perf_counter() - start
264+
metric_error_type = error_type or (
265+
"api_error" if result is not None and getattr(result, "error", None) else None
266+
)
267+
_record_metrics(tool_name, action, elapsed, metric_error_type)
268+
269+
if result is not None and getattr(result, "error", None):
270+
_record_span_error(span, "api_error")
271+
return result

0 commit comments

Comments
 (0)