Repository navigation
mcp: add structured analytics logging #319
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| """Payload-free JSON events for MCP usage and latency analytics.""" | ||
|
|
||
| import json | ||
| import logging | ||
| import time | ||
| import uuid | ||
| from contextlib import contextmanager | ||
| from datetime import datetime, timezone | ||
|
|
||
| import anyio | ||
| from mcp.types import CallToolRequest | ||
|
|
||
| logger = logging.getLogger("kcidev.mcp.analytics") | ||
|
|
||
|
|
||
| def log_event(event, **fields): | ||
| logger.info( | ||
| json.dumps( | ||
| { | ||
| "timestamp": datetime.now(timezone.utc).isoformat(), | ||
| "event": event, | ||
| **fields, | ||
| } | ||
| ) | ||
| ) | ||
|
|
||
|
|
||
| @contextmanager | ||
| def analytics_logging(path=None): | ||
| """Configure only our analytics logger; keep stdout free for MCP.""" | ||
| handler = ( | ||
| logging.FileHandler(path, encoding="utf-8") | ||
| if path is not None | ||
| else logging.StreamHandler() | ||
| ) | ||
| previous = logger.handlers[:], logger.level, logger.propagate | ||
| logger.handlers = [handler] | ||
| logger.setLevel(logging.INFO) | ||
| logger.propagate = False | ||
| try: | ||
| yield | ||
| finally: | ||
| logger.handlers, logger.level, logger.propagate = previous | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assigning logger.level directly leaves Python’s enabled-level cache stale. After restoring WARNING, I confirmed that INFO events were still emitted. Restore the level through logger.setLevel(previous_level) and test logging after the context exits. |
||
| handler.close() | ||
|
|
||
|
|
||
| def instrument_tools(server, instance): | ||
| # Wrap the protocol handler so schema errors and unknown tools count too. | ||
| # FastMCP converts tool exceptions to isError results inside this handler. | ||
| handlers = server._mcp_server.request_handlers | ||
| call_tool = handlers[CallToolRequest] | ||
|
|
||
| async def logged_call(request): | ||
| started = time.perf_counter() | ||
| call_id = uuid.uuid4().hex | ||
| outcome = "error" | ||
| try: | ||
| result = await call_tool(request) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Existing tools use tool_offload(), whose worker execution is shielded from cancellation. Check for pending cancellation after the handler returns, and add a protocol-level cancellation test using a real registered tool. The current test only covers an artificial async tool. |
||
| outcome = "error" if result.root.isError else "success" | ||
| return result | ||
| except anyio.get_cancelled_exc_class(): | ||
| outcome = "cancelled" | ||
| raise | ||
| finally: | ||
| log_event( | ||
| "tool_call", | ||
| call_id=call_id, | ||
| tool=request.params.name, | ||
| instance=instance, | ||
| outcome=outcome, | ||
| duration_ms=round((time.perf_counter() - started) * 1000, 3), | ||
| ) | ||
|
|
||
| handlers[CallToolRequest] = logged_call | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| import json | ||
| from unittest.mock import Mock | ||
|
|
||
| import pytest | ||
|
|
||
| pytest.importorskip("mcp") | ||
|
|
||
| import anyio | ||
| from click.testing import CliRunner | ||
| from mcp.shared.memory import ( | ||
| create_connected_server_and_client_session as client_session, | ||
| ) | ||
|
|
||
| from kcidev.mcp import create_server | ||
| from kcidev.mcp.analytics import analytics_logging, log_event | ||
| from kcidev.subcommands import mcp | ||
|
|
||
|
|
||
| def test_tool_analytics_covers_success_failure_and_validation(tmp_path): | ||
| server = create_server() | ||
|
|
||
| @server.tool() | ||
| async def example(value: int): | ||
| if value == 0: | ||
| raise ValueError("private exception text") | ||
| return "private result text" | ||
|
|
||
| path = tmp_path / "analytics.jsonl" | ||
|
|
||
| async def run(): | ||
| async with client_session(server._mcp_server) as session: | ||
| for name, arguments, error in [ | ||
| ("example", {"value": 1}, False), | ||
| ("example", {"value": 0}, True), | ||
| ("example", {"value": "private argument text"}, True), | ||
| ("missing_tool", {}, True), | ||
| ]: | ||
| result = await session.call_tool(name, arguments) | ||
| assert result.isError is error | ||
|
|
||
| with analytics_logging(path): | ||
| anyio.run(run) | ||
|
|
||
| text = path.read_text() | ||
| assert "private" not in text | ||
| events = [json.loads(line) for line in text.splitlines()] | ||
| assert len(events) == 4 | ||
| assert [event["outcome"] for event in events] == [ | ||
| "success", | ||
| "error", | ||
| "error", | ||
| "error", | ||
| ] | ||
| assert len({event["call_id"] for event in events}) == 4 | ||
| for event in events: | ||
| assert event["event"] == "tool_call" | ||
| assert event["duration_ms"] >= 0 | ||
| assert event["timestamp"].endswith("+00:00") | ||
| assert "instance" in event | ||
|
|
||
|
|
||
| def test_logging_appends_without_duplicate_handlers(tmp_path): | ||
| path = tmp_path / "analytics.jsonl" | ||
| for _ in range(2): | ||
| with analytics_logging(path): | ||
| log_event("server_start") | ||
| assert len(path.read_text().splitlines()) == 2 | ||
|
|
||
|
|
||
| def test_default_logging_uses_stderr(capsys): | ||
| with analytics_logging(): | ||
| log_event("server_start") | ||
| output = capsys.readouterr() | ||
| assert output.out == "" | ||
| assert json.loads(output.err)["event"] == "server_start" | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("transport", ["stdio", "http"]) | ||
| def test_cli_lifecycle_logging(tmp_path, monkeypatch, transport): | ||
| server = Mock() | ||
| monkeypatch.setattr("kcidev.mcp.create_server", Mock(return_value=server)) | ||
|
|
||
| async def run_stdio(server): | ||
| pass | ||
|
|
||
| monkeypatch.setattr(mcp, "_run_stdio", run_stdio) | ||
| path = tmp_path / "analytics.jsonl" | ||
| result = CliRunner().invoke( | ||
| mcp.mcp, | ||
| ["--transport", transport, "--log-file", str(path)], | ||
| obj={}, | ||
| ) | ||
| assert result.exit_code == 0, result.output | ||
| events = [json.loads(line) for line in path.read_text().splitlines()] | ||
| assert [event["event"] for event in events] == ["server_start", "server_stop"] | ||
| assert all(event["transport"] == transport for event in events) | ||
|
|
||
|
|
||
| def test_cli_reports_unwritable_log_path(tmp_path): | ||
| result = CliRunner().invoke( | ||
| mcp.mcp, ["--log-file", str(tmp_path / "missing" / "log")], obj={} | ||
| ) | ||
| assert result.exit_code == 1 | ||
| assert "Cannot open analytics log" in result.output | ||
|
|
||
|
|
||
| def test_cancelled_tool_is_logged(tmp_path): | ||
| server = create_server() | ||
|
|
||
| @server.tool() | ||
| async def waiting(): | ||
| await anyio.sleep_forever() | ||
|
|
||
| path = tmp_path / "analytics.jsonl" | ||
|
|
||
| async def run(): | ||
| from mcp.types import CallToolRequest, CallToolRequestParams | ||
|
|
||
| handler = server._mcp_server.request_handlers[CallToolRequest] | ||
| with anyio.move_on_after(0.05) as scope: | ||
| await handler(CallToolRequest(params=CallToolRequestParams(name="waiting"))) | ||
| assert scope.cancel_called | ||
|
|
||
| with analytics_logging(path): | ||
| anyio.run(run) | ||
| event = json.loads(path.read_text()) | ||
| assert event["outcome"] == "cancelled" | ||
| assert event["tool"] == "waiting" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation delegates rotation externally, but FileHandler never reopens a replaced file. I reproduced this by renaming the log and creating its replacement: subsequent events went into the rotated file, while the current file stayed empty. Use WatchedFileHandler, provide a reopen mechanism, or explicitly document the supported rotation procedure.