chore: add configurable serializer

This commit is contained in:
vincent 2025-08-26 22:01:11 +08:00 committed by Vincent Liu
commit 36975a5182
2 changed files with 93 additions and 6 deletions

View file

@ -2,8 +2,11 @@
import json
import logging
from collections.abc import Callable
from typing import Any
import pydantic_core
from .middleware import CallNext, Middleware, MiddlewareContext
@ -111,6 +114,7 @@ class StructuredLoggingMiddleware(Middleware):
log_level: int = logging.INFO,
include_payloads: bool = False,
methods: list[str] | None = None,
serializer: Callable[[Any], Any] | None = None,
):
"""Initialize structured logging middleware.
@ -119,11 +123,33 @@ class StructuredLoggingMiddleware(Middleware):
log_level: Log level for messages (default: INFO)
include_payloads: Whether to include message payloads in logs
methods: List of methods to log. If None, logs all methods.
serializer: Optional callable to convert objects to JSON-serializable
values when logging. Defaults to a safe converter that tries
pydantic_core.to_jsonable_python and falls back to str.
"""
self.logger = logger or logging.getLogger("fastmcp.structured")
self.log_level = log_level
self.include_payloads = include_payloads
self.methods = methods
self.serializer = serializer
def _json_default(self, obj: Any) -> Any:
"""Default converter for json.dumps to handle non-serializable objects.
Tries a user-provided serializer first, then pydantic conversion, then str.
"""
if self.serializer is not None:
try:
return self.serializer(obj)
except Exception:
pass
try:
return pydantic_core.to_jsonable_python(obj)
except Exception:
try:
return str(obj)
except Exception:
return "<non-serializable>"
def _create_log_entry(
self, context: MiddlewareContext, event: str, **extra_fields
@ -152,7 +178,9 @@ class StructuredLoggingMiddleware(Middleware):
if self.methods and context.method not in self.methods:
return await call_next(context)
self.logger.log(self.log_level, json.dumps(start_entry, default=str))
self.logger.log(
self.log_level, json.dumps(start_entry, default=self._json_default)
)
try:
result = await call_next(context)
@ -162,7 +190,9 @@ class StructuredLoggingMiddleware(Middleware):
"request_success",
result_type=type(result).__name__ if result else None,
)
self.logger.log(self.log_level, json.dumps(success_entry, default=str))
self.logger.log(
self.log_level, json.dumps(success_entry, default=self._json_default)
)
return result
except Exception as e:
@ -172,5 +202,7 @@ class StructuredLoggingMiddleware(Middleware):
error_type=type(e).__name__,
error_message=str(e),
)
self.logger.log(logging.ERROR, json.dumps(error_entry, default=str))
self.logger.log(
logging.ERROR, json.dumps(error_entry, default=self._json_default)
)
raise

View file

@ -216,7 +216,7 @@ class TestStructuredLoggingMiddleware:
async def test_on_message_with_resource_template_in_payload(
self, mock_context, mock_call_next, caplog
):
"""Ensure ResourceTemplate in payload serializes via default=str without errors."""
"""Ensure ResourceTemplate in payload serializes via pydantic conversion without errors."""
from fastmcp.resources import ResourceTemplate
template = ResourceTemplate(
@ -239,8 +239,63 @@ class TestStructuredLoggingMiddleware:
start_entry = json.loads(log_lines[0])
assert start_entry["event"] == "request_start"
assert "template" in start_entry["payload"]
# After json.loads, default=str ensures complex object became a JSON string
assert isinstance(start_entry["payload"]["template"], str)
# With pydantic conversion, complex object becomes a JSONable dict
assert isinstance(start_entry["payload"]["template"], dict)
assert start_entry["payload"]["template"]["uri_template"] == "tmpl://{id}"
async def test_on_message_with_nonserializable_payload_falls_back_to_str(
self, mock_context, mock_call_next, caplog
):
"""Ensure non-JSONable objects fall back to string serialization in payload."""
class NonSerializable:
def __str__(self) -> str:
return "NON_SERIALIZABLE"
mock_context.message.__dict__["obj"] = NonSerializable()
middleware = StructuredLoggingMiddleware(include_payloads=True)
with caplog.at_level(logging.INFO):
result = await middleware.on_message(mock_context, mock_call_next)
assert result == "test_result"
log_lines = [record.message for record in caplog.records]
assert len(log_lines) >= 2
start_entry = json.loads(log_lines[0])
assert start_entry["event"] == "request_start"
assert start_entry["payload"]["obj"] == "NON_SERIALIZABLE"
async def test_on_message_with_custom_serializer_applied(
self, mock_context, mock_call_next, caplog
):
"""Ensure a custom serializer is used for non-JSONable payloads."""
class CustomType:
pass
def custom_serializer(o):
if isinstance(o, CustomType):
return "CUSTOM:CustomType"
raise TypeError("unsupported")
mock_context.message.__dict__["special"] = CustomType()
middleware = StructuredLoggingMiddleware(
include_payloads=True, serializer=custom_serializer
)
with caplog.at_level(logging.INFO):
result = await middleware.on_message(mock_context, mock_call_next)
assert result == "test_result"
log_lines = [record.message for record in caplog.records]
assert len(log_lines) >= 2
start_entry = json.loads(log_lines[0])
assert start_entry["event"] == "request_start"
assert start_entry["payload"]["special"] == "CUSTOM:CustomType"
@pytest.fixture