mcp-telemetry 0.2.0__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.
- mcp_telemetry/__init__.py +41 -0
- mcp_telemetry/api.py +98 -0
- mcp_telemetry/cli.py +90 -0
- mcp_telemetry/context.py +17 -0
- mcp_telemetry/exporters.py +117 -0
- mcp_telemetry/fastmcp.py +54 -0
- mcp_telemetry/metrics.py +157 -0
- mcp_telemetry/model.py +74 -0
- mcp_telemetry/monkey.py +108 -0
- mcp_telemetry/otel_provider.py +171 -0
- mcp_telemetry/propagator.py +63 -0
- mcp_telemetry/redact.py +54 -0
- mcp_telemetry/replay.py +114 -0
- mcp_telemetry/sampler.py +97 -0
- mcp_telemetry/store.py +63 -0
- mcp_telemetry-0.2.0.dist-info/METADATA +132 -0
- mcp_telemetry-0.2.0.dist-info/RECORD +21 -0
- mcp_telemetry-0.2.0.dist-info/WHEEL +5 -0
- mcp_telemetry-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_telemetry-0.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_telemetry-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""mcp-telemetry — zero-config observability for MCP servers.
|
|
2
|
+
|
|
3
|
+
Core is stdlib-only. Optional exporters:
|
|
4
|
+
- OTLP/HTTP (needs httpx) -> pip install mcp-telemetry[otlp]
|
|
5
|
+
- built-in JSONL + live TUI terminal panel (no deps)
|
|
6
|
+
|
|
7
|
+
Extended surface — propagation, sampling, metrics, OTel provider, replay:
|
|
8
|
+
from mcp_telemetry.propagator import generate_traceparent, parse_traceparent
|
|
9
|
+
from mcp_telemetry.sampler import parent_based, ratio, rate_limited
|
|
10
|
+
from mcp_telemetry.metrics import Registry, metrics_from_store
|
|
11
|
+
from mcp_telemetry.otel_provider import OtelProvider
|
|
12
|
+
from mcp_telemetry.fastmcp import patch_fastmcp, make_server
|
|
13
|
+
mcp-trace --replay store.jsonl --console
|
|
14
|
+
"""
|
|
15
|
+
from mcp_telemetry.api import (
|
|
16
|
+
auto,
|
|
17
|
+
instrument,
|
|
18
|
+
session,
|
|
19
|
+
span,
|
|
20
|
+
trace_store,
|
|
21
|
+
)
|
|
22
|
+
from mcp_telemetry import monkey
|
|
23
|
+
from mcp_telemetry.monkey import wrap_tool_call, patch_mcp_sdk
|
|
24
|
+
from mcp_telemetry.store import TraceStore, default_store, set_default_store
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"auto",
|
|
28
|
+
"instrument",
|
|
29
|
+
"session",
|
|
30
|
+
"span",
|
|
31
|
+
"trace_store",
|
|
32
|
+
"monkey",
|
|
33
|
+
"wrap_tool_call",
|
|
34
|
+
"patch_mcp_sdk",
|
|
35
|
+
"TraceStore",
|
|
36
|
+
"default_store",
|
|
37
|
+
"set_default_store",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
__version__ = "0.2.0"
|
mcp_telemetry/api.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Public API for mcp-telemetry.
|
|
2
|
+
|
|
3
|
+
import mcp_telemetry as mt
|
|
4
|
+
mt.auto() # patch official mcp SDK + fanout to JSONL
|
|
5
|
+
mt.instrument(exporter=..., scrubbed=True)
|
|
6
|
+
|
|
7
|
+
with mt.span("chat"): # manual span around an LLM call
|
|
8
|
+
...
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from typing import Iterator, Optional
|
|
16
|
+
|
|
17
|
+
from mcp_telemetry import context as _ctx
|
|
18
|
+
from mcp_telemetry import exporters
|
|
19
|
+
from mcp_telemetry import model
|
|
20
|
+
from mcp_telemetry import redact
|
|
21
|
+
from mcp_telemetry.monkey import is_patched, patch_mcp_sdk, wrap_tool_call
|
|
22
|
+
from mcp_telemetry.store import TraceStore, default_store, set_default_store
|
|
23
|
+
|
|
24
|
+
_ENABLED = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def instrument(
|
|
28
|
+
store: Optional[TraceStore] = None,
|
|
29
|
+
trace_exporter: Optional[exporters.Exporter] = None,
|
|
30
|
+
enabled: bool = True,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Configure the active store + exporter fanout."""
|
|
33
|
+
global _ENABLED
|
|
34
|
+
eff = store or default_store()
|
|
35
|
+
if store is not None:
|
|
36
|
+
set_default_store(store)
|
|
37
|
+
_ENABLED = enabled
|
|
38
|
+
eff.on_record = exporters.DEFAULT.export if enabled else None
|
|
39
|
+
if trace_exporter is not None:
|
|
40
|
+
exporters.DEFAULT._targets = [trace_exporter]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def auto(store: Optional[TraceStore] = None, enabled: bool = True) -> bool:
|
|
44
|
+
"""All-in-one: enforce defaults + patch the official mcp SDK.
|
|
45
|
+
|
|
46
|
+
If the mcp SDK is missing, patch is a no-op and auto() still returns True
|
|
47
|
+
so callers can instrument() without knowing the runtime shape.
|
|
48
|
+
"""
|
|
49
|
+
instrument(store=store, enabled=enabled)
|
|
50
|
+
if enabled and "MCP_TELEMETRY_DISABLE" not in os.environ:
|
|
51
|
+
patch_mcp_sdk(store)
|
|
52
|
+
if enabled and sys.stderr.isatty():
|
|
53
|
+
exporters.DEFAULT.add(exporters.TextExporter())
|
|
54
|
+
return patch_mcp_sdk(store) or True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def session(trace_id: Optional[str] = None):
|
|
58
|
+
"""Context manager: start a trace, attach all nested spans to it."""
|
|
59
|
+
store = default_store()
|
|
60
|
+
trace = store.start(trace_id)
|
|
61
|
+
prev = _ctx.get_current()
|
|
62
|
+
_ctx.set_current(trace)
|
|
63
|
+
|
|
64
|
+
@contextmanager
|
|
65
|
+
def _scope() -> Iterator["TraceStore"]:
|
|
66
|
+
try:
|
|
67
|
+
yield store
|
|
68
|
+
finally:
|
|
69
|
+
store.record(trace)
|
|
70
|
+
_ctx.set_current(prev)
|
|
71
|
+
|
|
72
|
+
return _scope()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@contextmanager
|
|
76
|
+
def span(name: str, kind: str = model.SPAN_AGENT) -> Iterator[model.Span]:
|
|
77
|
+
"""Manual span. Attaches to the ambient session trace if one is open,
|
|
78
|
+
otherwise starts a standalone trace."""
|
|
79
|
+
store = default_store()
|
|
80
|
+
current = _ctx.get_current()
|
|
81
|
+
if current is not None:
|
|
82
|
+
trace = current
|
|
83
|
+
else:
|
|
84
|
+
trace = store.start()
|
|
85
|
+
sp = trace._make_span(name, None, kind=kind)
|
|
86
|
+
try:
|
|
87
|
+
yield sp
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
sp.finish("ERROR", f"{type(exc).__name__}: {exc}")
|
|
90
|
+
store.record(trace, finished=current is None)
|
|
91
|
+
raise
|
|
92
|
+
else:
|
|
93
|
+
sp.finish("OK")
|
|
94
|
+
store.record(trace, finished=current is None)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def trace_store() -> TraceStore:
|
|
98
|
+
return default_store()
|
mcp_telemetry/cli.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""CLI: mcp-trace — render, tail, and clear the local telemetry feed.
|
|
2
|
+
|
|
3
|
+
mcp-trace # last 25 spans in ANSI table
|
|
4
|
+
mcp-trace --tail # follow the JSONL feed
|
|
5
|
+
mcp-trace --json # dump raw JSONL to stdout (pipe friendly)
|
|
6
|
+
mcp-trace --clear # truncate the local JSONL log
|
|
7
|
+
mcp-trace --replay <file> [--console] [--max N] # replay recorded traces through the exporter stack
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _read(path: str) -> list[dict]:
|
|
17
|
+
rows: list[dict] = []
|
|
18
|
+
try:
|
|
19
|
+
with open(path, encoding="utf-8") as fh:
|
|
20
|
+
for line in fh:
|
|
21
|
+
line = line.strip()
|
|
22
|
+
if line:
|
|
23
|
+
rows.append(json.loads(line))
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
pass
|
|
26
|
+
return rows
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _render(rows: list[dict]) -> None:
|
|
30
|
+
for tr in rows[-25:]:
|
|
31
|
+
for s in tr["spans"]:
|
|
32
|
+
hit = "OK" if s["status"] == "OK" else "ERR"
|
|
33
|
+
color = 32 if hit == "OK" else 31
|
|
34
|
+
print(
|
|
35
|
+
f"\x1b[36m{tr['id']}\x1b[0m "
|
|
36
|
+
f"\x1b[1m{s['tool']}\x1b[0m "
|
|
37
|
+
f"\x1b[{color}m{hit}\x1b[0m "
|
|
38
|
+
f"\x1b[33m{s.get('latency_ms') or 0:.1f}ms\x1b[0m "
|
|
39
|
+
f"in#{s.get('input_hash') or '-'} "
|
|
40
|
+
+ (f"\x1b[31m{s['error']}\x1b[0m" if s.get("error") else "")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main() -> int:
|
|
45
|
+
parser = argparse.ArgumentParser(prog="mcp-trace", description="Render the mcp-telemetry local feed")
|
|
46
|
+
parser.add_argument("--tail", action="store_true", help="follow the JSONL log")
|
|
47
|
+
parser.add_argument("--json", action="store_true", help="dump raw records as NDJSON")
|
|
48
|
+
parser.add_argument("--clear", action="store_true", help="clear the local log")
|
|
49
|
+
parser.add_argument("--replay", metavar="FILE", help="replay a recorded JSONL store through the exporter stack")
|
|
50
|
+
parser.add_argument("--console", action="store_true", help="with --replay, print OTLP-shaped traces")
|
|
51
|
+
parser.add_argument("--max", type=int, metavar="N", help="with --replay, cap the number of traces")
|
|
52
|
+
parser.add_argument("--path", default="mcp-telemetry.jsonl", help="log file path")
|
|
53
|
+
args = parser.parse_args()
|
|
54
|
+
|
|
55
|
+
if args.replay is not None:
|
|
56
|
+
from mcp_telemetry.replay import main as replay_main
|
|
57
|
+
|
|
58
|
+
sub = [args.replay]
|
|
59
|
+
if args.console:
|
|
60
|
+
sub.append("--console")
|
|
61
|
+
if args.max:
|
|
62
|
+
sub += ["--max", str(args.max)]
|
|
63
|
+
return replay_main(sub)
|
|
64
|
+
|
|
65
|
+
if args.clear:
|
|
66
|
+
open(args.path, "w", encoding="utf-8").close() # noqa: SIM115
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
if args.tail:
|
|
70
|
+
seen = 0
|
|
71
|
+
while True:
|
|
72
|
+
rows = _read(args.path)
|
|
73
|
+
for tr in rows[seen:]:
|
|
74
|
+
for s in tr["spans"]:
|
|
75
|
+
print(json.dumps({"trace": tr["id"], **s}))
|
|
76
|
+
seen += 1
|
|
77
|
+
time.sleep(0.5)
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
rows = _read(args.path)
|
|
81
|
+
if args.json:
|
|
82
|
+
for tr in rows:
|
|
83
|
+
print(json.dumps(tr))
|
|
84
|
+
return 0
|
|
85
|
+
_render(rows)
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
raise SystemExit(main())
|
mcp_telemetry/context.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Ambient trace cursor shared across api.py and monkey.py without cycles."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from mcp_telemetry.model import Trace
|
|
7
|
+
|
|
8
|
+
_current: Optional[Trace] = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def set_current(trace: Optional[Trace]) -> None:
|
|
12
|
+
global _current
|
|
13
|
+
_current = trace
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_current() -> Optional[Trace]:
|
|
17
|
+
return _current
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Span payload builders + exporters.
|
|
2
|
+
|
|
3
|
+
Exporters implement `dump(trace) -> str` so they compose cleanly:
|
|
4
|
+
- JsonlExporter -> ndjson for pipes / local debug
|
|
5
|
+
- OtlpExporter -> OTLP/HTTP JSON payload (httpx optional extra)
|
|
6
|
+
- TextExporter -> ANSI table for the live TUI panel (stdlib only)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
|
|
17
|
+
from mcp_telemetry.model import Trace
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Exporter(Protocol):
|
|
21
|
+
def export(self, trace: Trace) -> None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class JsonlExporter:
|
|
26
|
+
path: str = "mcp-telemetry.jsonl"
|
|
27
|
+
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
28
|
+
|
|
29
|
+
def export(self, trace: Trace) -> None:
|
|
30
|
+
with self._lock:
|
|
31
|
+
with open(self.path, "a", encoding="utf-8") as fh:
|
|
32
|
+
fh.write(json.dumps(trace.to_dict()) + "\n")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class TextExporter:
|
|
37
|
+
"""Minimal ANSI 256-color renderer. No external deps."""
|
|
38
|
+
|
|
39
|
+
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
40
|
+
|
|
41
|
+
def export(self, trace: Trace) -> None:
|
|
42
|
+
rows = []
|
|
43
|
+
for s in trace.spans:
|
|
44
|
+
status = "\x1b[32mOK\x1b[0m" if s.status == "OK" else f"\x1b[31m{s.status}\x1b[0m"
|
|
45
|
+
lines = [
|
|
46
|
+
f" \x1b[36m{trace.id}\x1b[0m {s.tool or s.name} {status} "
|
|
47
|
+
f"\x1b[33m{s.latency_ms or 0:.1f}ms\x1b[0m"
|
|
48
|
+
]
|
|
49
|
+
if s.input_hash:
|
|
50
|
+
lines.append(f" input#{s.input_hash} {s.server or ''}")
|
|
51
|
+
if s.error:
|
|
52
|
+
lines.append(f" \x1b[31m{s.error}\x1b[0m")
|
|
53
|
+
rows.extend(lines)
|
|
54
|
+
payload = "\n".join(rows)
|
|
55
|
+
with self._lock:
|
|
56
|
+
print(payload, file=sys.stderr)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class OtlpExporter:
|
|
61
|
+
"""OTLP/HTTP JSON (v1 logs/bridge). Requires httpx (extra: [otlp]).
|
|
62
|
+
|
|
63
|
+
Sends one span record per tool call with gen_ai keys in attributes.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
endpoint: str = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/logs")
|
|
67
|
+
service_name: str = os.environ.get("OTEL_SERVICE_NAME", "mcp-server")
|
|
68
|
+
headers: dict[str, str] = field(default_factory=lambda: {"Content-Type": "application/json"})
|
|
69
|
+
|
|
70
|
+
def export(self, trace: Trace) -> None:
|
|
71
|
+
try:
|
|
72
|
+
import httpx # type: ignore
|
|
73
|
+
except ImportError as exc: # pragma: no cover
|
|
74
|
+
raise RuntimeError("OTLP export requires httpx: pip install 'mcp-telemetry[otlp]'") from exc
|
|
75
|
+
|
|
76
|
+
records = []
|
|
77
|
+
for s in trace.spans:
|
|
78
|
+
attrs = {
|
|
79
|
+
"gen_ai.tool.name": s.tool or s.name,
|
|
80
|
+
"gen_ai.server.name": s.server or "",
|
|
81
|
+
"gen_ai.input.bytes": s.input_hash or "",
|
|
82
|
+
"gen_ai.usage.input_tokens": s.tokens_in or 0,
|
|
83
|
+
"gen_ai.usage.output_tokens": s.tokens_out or 0,
|
|
84
|
+
"gen_ai.operation.span.id": trace.id,
|
|
85
|
+
"gen_ai.operation.name": s.name,
|
|
86
|
+
}
|
|
87
|
+
if s.error:
|
|
88
|
+
attrs["gen_ai.error.type"] = s.error
|
|
89
|
+
records.append(
|
|
90
|
+
{
|
|
91
|
+
"scope": {"name": "mcp-telemetry", "version": "0.1.0"},
|
|
92
|
+
"resource": {"service.name": self.service_name},
|
|
93
|
+
"severity_text": "ERROR" if s.error else "INFO",
|
|
94
|
+
"body": {"string_value": f"{s.tool} {s.status}"},
|
|
95
|
+
"attributes": attrs,
|
|
96
|
+
"timeUnixNano": str(int((s.end or s.start) * 1_000_000_000)),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
httpx.post(self.endpoint, json={"resourceLogs": [{"scopeLogs": [{"logRecords": records}]}]}, headers=self.headers, timeout=5.0)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Fanout:
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
self._targets: list[Exporter] = [JsonlExporter()]
|
|
105
|
+
|
|
106
|
+
def add(self, exporter: Exporter) -> None:
|
|
107
|
+
self._targets.append(exporter)
|
|
108
|
+
|
|
109
|
+
def export(self, trace: Trace) -> None:
|
|
110
|
+
for t in self._targets:
|
|
111
|
+
try:
|
|
112
|
+
t.export(trace)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
DEFAULT = Fanout()
|
mcp_telemetry/fastmcp.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""FastMCP integration — one-call instrumentation.
|
|
2
|
+
|
|
3
|
+
`patch_fastmcp(server)` wraps every tool handler the FastMCP server registers
|
|
4
|
+
so each invocation emits a GenAI-style trace span, deduped per tool, feeding
|
|
5
|
+
the active TraceStore/provider. No SDK import required to USE this module; the
|
|
6
|
+
SDK is fetched lazily so `pip install mcp-telemetry` alone is harmless.
|
|
7
|
+
|
|
8
|
+
If you do not run FastMCP, call `patch_mcp_sdk()` from the core to auto-wrap
|
|
9
|
+
the stdlib/sdk tool handlers instead — the same spans drop out underneath.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Callable, Optional
|
|
14
|
+
|
|
15
|
+
from mcp_telemetry.api import span
|
|
16
|
+
from mcp_telemetry.monkey import wrap_tool_call
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def patch_fastmcp(server: Any, *, store: Any = None) -> int:
|
|
20
|
+
"""Wrap all handlers currently registered on a FastMCP server object. The
|
|
21
|
+
instance may be post-`@server.tool(...)` decorated — we walk `server._tool_manager`."""
|
|
22
|
+
try:
|
|
23
|
+
import fastmcp # noqa: F401
|
|
24
|
+
except ImportError:
|
|
25
|
+
raise RuntimeError("fastmcp not installed — import it or pip install fastmcp")
|
|
26
|
+
|
|
27
|
+
tool_manager = getattr(server, "_tool_manager", None)
|
|
28
|
+
if tool_manager is None:
|
|
29
|
+
return 0
|
|
30
|
+
tools = getattr(tool_manager, "_tools", None) or getattr(tool_manager, "tools", {})
|
|
31
|
+
wrapped = 0
|
|
32
|
+
for tool in tools.values() if isinstance(tools, dict) else tools:
|
|
33
|
+
fn = getattr(tool, "fn", None) or getattr(tool, "func", None)
|
|
34
|
+
if fn is None:
|
|
35
|
+
continue
|
|
36
|
+
name = getattr(tool, "name", None) or getattr(fn, "__name__", "tool")
|
|
37
|
+
tool.fn = wrap_tool_call(fn, store=store, tool_name=name) # type: ignore[attr-defined]
|
|
38
|
+
wrapped += 1
|
|
39
|
+
return wrapped
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def make_server(name: str, store: Any) -> Any:
|
|
43
|
+
"""Slim FastMCP-compatible shim for a server name: emits a trace span per
|
|
44
|
+
tool invocation and records it. Use when you want instrumentation without
|
|
45
|
+
the SDK present — identical span shape, so replays report the same way."""
|
|
46
|
+
# class bodies do not close over enclosing function locals — build via type()
|
|
47
|
+
def tool(fn: Callable[..., Any], *, tool_name: Optional[str] = None) -> Callable[..., Any]:
|
|
48
|
+
return wrap_tool_call(fn, name=tool_name or getattr(fn, "__name__", "tool"), store=store)
|
|
49
|
+
|
|
50
|
+
shim = type("FastMcpShim", (), {"name": name, "tool": staticmethod(tool)})()
|
|
51
|
+
return shim
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["patch_fastmcp", "make_server"]
|
mcp_telemetry/metrics.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""In-process metrics registry — counters + latency histograms, zero-dep.
|
|
2
|
+
|
|
3
|
+
Aggregates tool-call counts and latency distributions in memory and exposes:
|
|
4
|
+
- snapshot() plain dict for JSON / admin dashboards
|
|
5
|
+
- openmetrics() Prometheus/OpenTelemetry text exposition
|
|
6
|
+
- metrics_from_store() auto-wire a registry to a TraceStore via on_record
|
|
7
|
+
Render negative-space: no units, no timestamps — just numbers, honestly kept.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
_BUCKETS_MS = (1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000)
|
|
15
|
+
|
|
16
|
+
# fine-grain buckets for UI-place histograms; coarser for exporter cost
|
|
17
|
+
_BUCKETS_COARSE = (10, 50, 100, 500, 1000, 5000)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Counter:
|
|
21
|
+
def __init__(self, name: str, labels: Optional[dict[str, str]] = None) -> None:
|
|
22
|
+
self.name = name
|
|
23
|
+
self.labels = labels or {}
|
|
24
|
+
self.value = 0
|
|
25
|
+
|
|
26
|
+
def inc(self, amount: int = 1) -> None:
|
|
27
|
+
self.value += amount
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Histogram:
|
|
31
|
+
def __init__(self, name: str, labels: Optional[dict[str, str]] = None, buckets: tuple[float, ...] = _BUCKETS_MS) -> None:
|
|
32
|
+
self.name = name
|
|
33
|
+
self.labels = labels or {}
|
|
34
|
+
self.buckets = buckets
|
|
35
|
+
self.counts = [0] * (len(buckets) + 1)
|
|
36
|
+
self.sum = 0.0
|
|
37
|
+
self.count = 0
|
|
38
|
+
self.min = float("inf")
|
|
39
|
+
self.max = 0.0
|
|
40
|
+
|
|
41
|
+
def observe(self, value_ms: float) -> None:
|
|
42
|
+
self.sum += value_ms
|
|
43
|
+
self.count += 1
|
|
44
|
+
if value_ms < self.min:
|
|
45
|
+
self.min = value_ms
|
|
46
|
+
if value_ms > self.max:
|
|
47
|
+
self.max = value_ms
|
|
48
|
+
for i, b in enumerate(self.buckets):
|
|
49
|
+
if value_ms <= b:
|
|
50
|
+
self.counts[i] += 1
|
|
51
|
+
break
|
|
52
|
+
else:
|
|
53
|
+
self.counts[-1] += 1
|
|
54
|
+
|
|
55
|
+
def snapshot(self) -> dict[str, Any]:
|
|
56
|
+
cumulative = 0
|
|
57
|
+
cdf: dict[str, int] = {}
|
|
58
|
+
for i, b in enumerate(self.buckets):
|
|
59
|
+
cumulative += self.counts[i]
|
|
60
|
+
cdf[f"le_{b:g}"] = cumulative
|
|
61
|
+
cdf["le_+Inf"] = self.count
|
|
62
|
+
p50 = self.quantile(0.5)
|
|
63
|
+
p95 = self.quantile(0.95)
|
|
64
|
+
p99 = self.quantile(0.99)
|
|
65
|
+
return {
|
|
66
|
+
"count": self.count,
|
|
67
|
+
"sum_ms": round(self.sum, 3),
|
|
68
|
+
"min_ms": 0.0 if self.min == float("inf") else round(self.min, 3),
|
|
69
|
+
"p50_ms": round(p50, 3),
|
|
70
|
+
"p95_ms": round(p95, 3),
|
|
71
|
+
"p99_ms": round(p99, 3),
|
|
72
|
+
"max_ms": round(self.max, 3),
|
|
73
|
+
"cdf": cdf,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def quantile(self, q: float) -> float:
|
|
77
|
+
if self.count == 0:
|
|
78
|
+
return 0.0
|
|
79
|
+
target = self.count * q
|
|
80
|
+
acc = 0
|
|
81
|
+
bounds = (0.0,) + self.buckets + (float("inf"),)
|
|
82
|
+
for i, hi in enumerate(bounds):
|
|
83
|
+
acc += self.counts[i]
|
|
84
|
+
if acc >= target:
|
|
85
|
+
lo = bounds[i]
|
|
86
|
+
if hi == float("inf"):
|
|
87
|
+
return lo
|
|
88
|
+
return lo + (hi - lo) * 0.5
|
|
89
|
+
return 0.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Registry:
|
|
93
|
+
"""Thread-safe metrics registry."""
|
|
94
|
+
|
|
95
|
+
def __init__(self) -> None:
|
|
96
|
+
self._lock = threading.Lock()
|
|
97
|
+
self._counters: list[Counter] = []
|
|
98
|
+
self._histograms: list[Histogram] = []
|
|
99
|
+
|
|
100
|
+
def counter(self, name: str, labels: Optional[dict[str, str]] = None) -> Counter:
|
|
101
|
+
with self._lock:
|
|
102
|
+
c = Counter(name, labels)
|
|
103
|
+
self._counters.append(c)
|
|
104
|
+
return c
|
|
105
|
+
|
|
106
|
+
def histogram(self, name: str, labels: Optional[dict[str, str]] = None) -> Histogram:
|
|
107
|
+
with self._lock:
|
|
108
|
+
h = Histogram(name, labels)
|
|
109
|
+
self._histograms.append(h)
|
|
110
|
+
return h
|
|
111
|
+
|
|
112
|
+
def snapshot(self) -> dict[str, Any]:
|
|
113
|
+
with self._lock:
|
|
114
|
+
counters = [{"name": c.name, "labels": c.labels, "value": c.value} for c in self._counters]
|
|
115
|
+
histos = [{"name": h.name, "labels": h.labels, **h.snapshot()} for h in self._histograms]
|
|
116
|
+
return {"counters": counters, "histograms": histos}
|
|
117
|
+
|
|
118
|
+
def openmetrics(self) -> str:
|
|
119
|
+
"""Prometheus text exposition (0.0.1)."""
|
|
120
|
+
lines = ["# TYPE mcp_metric counter", "# TYPE mcp_latency histogram"]
|
|
121
|
+
snap = self.snapshot()
|
|
122
|
+
for c in snap["counters"]:
|
|
123
|
+
labels = ",".join(f'{k}="{v}"' for k, v in c["labels"].items())
|
|
124
|
+
suffix = f"{{{labels}}}" if labels else ""
|
|
125
|
+
lines.append(f'mcp_metric{suffix} {c["value"]}')
|
|
126
|
+
for h in snap["histograms"]:
|
|
127
|
+
base = ",".join(f'{k}="{v}"' for k, v in h["labels"].items())
|
|
128
|
+
for le, val in h["cdf"].items():
|
|
129
|
+
lbl = f"{base},le=\"{le.removeprefix('le_')}\"" if base else f"le=\"{le.removeprefix('le_')}\""
|
|
130
|
+
lines.append(f'mcp_latency_bucket{{{lbl}}} {val}')
|
|
131
|
+
suffix = f"{{{base}}}" if base else ""
|
|
132
|
+
lines.append(f'mcp_latency_sum{suffix} {h["sum_ms"]}')
|
|
133
|
+
lines.append(f'mcp_latency_count{suffix} {h["count"]}')
|
|
134
|
+
return "\n".join(lines) + "\n"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def metrics_from_store(store: Any) -> Registry:
|
|
138
|
+
"""Wire a registry to a TraceStore's on_record hook: one counter per tool
|
|
139
|
+
status, one latency histogram per tool."""
|
|
140
|
+
reg = Registry()
|
|
141
|
+
histos: dict[str, Histogram] = {}
|
|
142
|
+
counters: dict[str, Counter] = {}
|
|
143
|
+
|
|
144
|
+
def on_record(trace: Any) -> None:
|
|
145
|
+
for span in trace.spans:
|
|
146
|
+
if not span.tool:
|
|
147
|
+
continue
|
|
148
|
+
key = span.tool
|
|
149
|
+
if key not in histos:
|
|
150
|
+
histos[key] = reg.histogram("mcp_tool_latency", {"tool": key})
|
|
151
|
+
counters[key] = reg.counter("mcp_calls", {"tool": key, "status": "OK"})
|
|
152
|
+
histos[key].observe(float(span.latency_ms or 0))
|
|
153
|
+
counters[key].inc()
|
|
154
|
+
counters[key].labels["status"] = span.status
|
|
155
|
+
|
|
156
|
+
store.on_record = on_record # type: ignore[attr-defined]
|
|
157
|
+
return reg
|
mcp_telemetry/model.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Trace/span model aligned with OpenTelemetry GenAI conventions.
|
|
2
|
+
|
|
3
|
+
Span kinds we emit, matching the OTel GenAI semantic conventions:
|
|
4
|
+
gen_ai.client.tool_call -> tool invocation span
|
|
5
|
+
gen_ai.client.op -> chat / invoke_agent spans
|
|
6
|
+
|
|
7
|
+
Every span carries: name in walrus form, stable kinds for the tool layer,
|
|
8
|
+
and an input *fingerprint* (hash) instead of raw payloads by default.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field, asdict
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
SPAN_TOOL = "gen_ai.client.tool_call"
|
|
17
|
+
SPAN_CHAT = "gen_ai.client.op"
|
|
18
|
+
SPAN_AGENT = "gen_ai.agent.invoke"
|
|
19
|
+
|
|
20
|
+
t0 = time.time()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _wall() -> float:
|
|
24
|
+
return time.time() - t0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Span:
|
|
29
|
+
name: str
|
|
30
|
+
kind: str = SPAN_TOOL
|
|
31
|
+
tool: Optional[str] = None
|
|
32
|
+
server: Optional[str] = None
|
|
33
|
+
start: float = field(default_factory=_wall)
|
|
34
|
+
end: Optional[float] = None
|
|
35
|
+
status: str = "OK"
|
|
36
|
+
error: Optional[str] = None
|
|
37
|
+
input_hash: Optional[str] = None
|
|
38
|
+
latency_ms: Optional[float] = None
|
|
39
|
+
tokens_in: Optional[int] = None
|
|
40
|
+
tokens_out: Optional[int] = None
|
|
41
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
parent_span_id: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
def finish(self, status: str = "OK", error: Optional[str] = None) -> "Span":
|
|
45
|
+
self.end = _wall()
|
|
46
|
+
self.latency_ms = round((self.end - self.start) * 1000, 3)
|
|
47
|
+
self.status = status
|
|
48
|
+
self.error = error
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
return asdict(self)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Trace:
|
|
57
|
+
id: str
|
|
58
|
+
started: float = field(default_factory=_wall)
|
|
59
|
+
spans: list[Span] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
def record(self, span: Span) -> None:
|
|
62
|
+
self.spans.append(span)
|
|
63
|
+
|
|
64
|
+
def _make_span(self, tool: str, server: Optional[str] = None, kind: str = SPAN_TOOL) -> Span:
|
|
65
|
+
span = Span(name=f"{tool} {kind}", tool=tool, server=server, kind=kind)
|
|
66
|
+
self.record(span)
|
|
67
|
+
return span
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
return {"id": self.id, "started": round(self.started, 3), "spans": [s.to_dict() for s in self.spans]}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def new_trace(trace_id: Optional[str] = None) -> Trace:
|
|
74
|
+
return Trace(id=trace_id or f"{int(time.time_ns())}")
|