From 72aea429434d744b36a5f5f1e4a219cc29704f8d Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri <123409429+Kayvan-Zahiri@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:53:18 -0700 Subject: [PATCH] fix: preserve request attributes on shared log records --- logtail/frame.py | 6 +-- tests/test_record_request.py | 75 ++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 tests/test_record_request.py diff --git a/logtail/frame.py b/logtail/frame.py index 4820c76..d8fd4ae 100644 --- a/logtail/frame.py +++ b/logtail/frame.py @@ -10,9 +10,6 @@ def create_frame(record: logging.LogRecord, message: str, context: LogtailContext, include_extra_attributes: bool = False) -> dict[str, Any]: r = record.__dict__ - # Django sends a request object in the record, which is not JSON serializable - if "request" in r and not isinstance(r["request"], (dict, list, bool, int, float, str)) : - del r["request"] frame: dict[str, Any] = {} frame['dt'] = datetime.fromtimestamp(r['created'], timezone.utc).isoformat() frame['level'] = _levelname(r['levelname']) @@ -58,6 +55,9 @@ def _parse_custom_events(record: logging.LogRecord, include_extra_attributes: bo for key, val in record.__dict__.items(): if key in default_keys: continue + # Omit Django request objects without mutating the shared LogRecord. + if key == "request" and not isinstance(val, (dict, list, bool, int, float, str)): + continue if not include_extra_attributes and not isinstance(val, dict): continue events[key] = val diff --git a/tests/test_record_request.py b/tests/test_record_request.py new file mode 100644 index 0000000..7a6f4e0 --- /dev/null +++ b/tests/test_record_request.py @@ -0,0 +1,75 @@ +import json +import logging +import unittest +from io import StringIO +from unittest.mock import patch + +from logtail import LogtailFormatter, LogtailHandler +from logtail.helpers import LogtailContext + + +class CapturingHandler(logging.Handler): + def emit(self, record): + self.request = getattr(record, 'request', self) + + +class TestLogRecordRequest(unittest.TestCase): + def check_formatter(self, request, included): + stream = StringIO() + first = logging.StreamHandler(stream) + first.setFormatter(LogtailFormatter(context=LogtailContext())) + second = CapturingHandler() + logger = logging.Logger('request-preservation') + logger.addHandler(first) + logger.addHandler(second) + try: + logger.info('example', extra={'request': request}) + frame = json.loads(stream.getvalue()) + self.assertEqual('request' in frame, included) + if included: + self.assertEqual(frame['request'], request) + self.assertIs(second.request, request) + finally: + first.close() + second.close() + + def check_handler(self, request, included): + with patch('logtail.handler.Uploader') as uploader: + handler = LogtailHandler('local-test', context=LogtailContext()) + record = logging.LogRecord('example', logging.INFO, __file__, 1, + 'example', (), None) + record.request = request + try: + with patch.object(handler, 'ensure_flush_thread_alive'): + handler.emit(record) + frame = handler.pipe.get_nowait() + self.assertEqual('request' in frame, included) + if included: + self.assertEqual(frame['request'], request) + self.assertIn('request', record.__dict__) + self.assertIs(record.request, request) + uploader.return_value.assert_not_called() + finally: + handler.close() + + def test_formatter_preserves_request_object_for_next_handler(self): + self.check_formatter(object(), False) + + def test_formatter_preserves_none_for_next_handler(self): + self.check_formatter(None, False) + + def test_handler_preserves_request_object(self): + self.check_handler(object(), False) + + def test_handler_preserves_none(self): + self.check_handler(None, False) + + def test_formatter_keeps_supported_request_values(self): + for request in ({'path': '/'}, ['example'], False, 0, 1.5, 'example'): + with self.subTest(request=request): + self.check_formatter(request, True) + + def test_handler_keeps_supported_request_values(self): + for request in ({'path': '/'}, ['example'], False, 0, 1.5, 'example'): + with self.subTest(request=request): + self.check_handler(request, True)