mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 13:04:18 +02:00
chore: add configurable serializer
This commit is contained in:
parent
76a0ed8954
commit
36975a5182
2 changed files with 93 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue