From b0501f0730200680a3d44abf9abfdcd8d0e56e71 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Mon, 2 Nov 2026 11:51:33 -0500 Subject: [PATCH] feat!: Enable event payload compression by default The SDK now gzip-compresses analytics event payloads unless enable_event_compression is set to False, on both Config and AsyncConfig. Compression reduces the bandwidth that event delivery uses. Relay Proxy versions before 8.9 cannot accept compressed events. If you send events through such a Relay Proxy, set enable_event_compression=False. --- ldclient/async_config.py | 8 ++++-- ldclient/config.py | 8 ++++-- ldclient/testing/http_util.py | 6 +++- .../impl/events/test_async_event_processor.py | 28 ++++++++++++++++--- .../impl/events/test_event_processor.py | 25 +++++++++++++++++ ldclient/testing/stub_util.py | 15 ++++++++++ ldclient/testing/test_config.py | 14 ++++++++++ 7 files changed, 95 insertions(+), 9 deletions(-) diff --git a/ldclient/async_config.py b/ldclient/async_config.py index 7e28a680..df94503b 100644 --- a/ldclient/async_config.py +++ b/ldclient/async_config.py @@ -174,7 +174,7 @@ def __init__( application: Optional[dict] = None, hooks: Optional[List[AsyncHook]] = None, plugins: Optional[List[AsyncPlugin]] = None, - enable_event_compression: bool = False, + enable_event_compression: bool = True, omit_anonymous_contexts: bool = False, payload_filter_key: Optional[str] = None, datasystem_config: Optional[AsyncDataSystemConfig] = None, @@ -245,7 +245,11 @@ def __init__( :param application: Optional properties for setting application metadata. See :py:attr:`~application` :param hooks: Hooks provide entrypoints which allow for observation of SDK functions. :param plugins: A list of plugins to be used with the SDK. Plugin support is currently experimental and subject to change. - :param enable_event_compression: Whether or not to enable GZIP compression for outgoing events. + :param enable_event_compression: Whether to use GZIP compression for the event payloads that the SDK + sends. The default is True. Set this to False if you send events to a Relay Proxy older than + version 8.9, because those versions cannot accept compressed events. Relay Proxy users can also + set this to False to prevent extra CPU use, because the Relay Proxy decompresses and recompresses + the payloads. :param omit_anonymous_contexts: Sets whether anonymous contexts should be omitted from index and identify events. :param payload_filter_key: The payload filter is used to selectively limited the flags and segments delivered in the data source payload. Payload filtering is not supported with the FDv2 data system, so this has no effect on FDv2 requests. :param datasystem_config: Configuration for the data system, which controls how the SDK gets flag and segment data. When set, it overrides ``feature_store``, ``update_processor_class``, ``stream``, and ``use_ldd``. diff --git a/ldclient/config.py b/ldclient/config.py index 9e7bd263..81271ee8 100644 --- a/ldclient/config.py +++ b/ldclient/config.py @@ -323,7 +323,7 @@ def __init__( application: Optional[dict] = None, hooks: Optional[List[Hook]] = None, plugins: Optional[List[Plugin]] = None, - enable_event_compression: bool = False, + enable_event_compression: bool = True, omit_anonymous_contexts: bool = False, payload_filter_key: Optional[str] = None, datasystem_config: Optional[DataSystemConfig] = None, @@ -394,7 +394,11 @@ def __init__( :param application: Optional properties for setting application metadata. See :py:attr:`~application` :param hooks: Hooks provide entrypoints which allow for observation of SDK functions. :param plugins: A list of plugins to be used with the SDK. Plugin support is currently experimental and subject to change. - :param enable_event_compression: Whether or not to enable GZIP compression for outgoing events. + :param enable_event_compression: Whether to use GZIP compression for the event payloads that the SDK + sends. The default is True. Set this to False if you send events to a Relay Proxy older than + version 8.9, because those versions cannot accept compressed events. Relay Proxy users can also + set this to False to prevent extra CPU use, because the Relay Proxy decompresses and recompresses + the payloads. :param omit_anonymous_contexts: Sets whether anonymous contexts should be omitted from index and identify events. :param payload_filter_key: The payload filter is used to selectively limited the flags and segments delivered in the data source payload. Payload filtering is not supported with the FDv2 data system, so this has no effect on FDv2 requests. :param datasystem_config: Configuration for the data system, which controls how the SDK gets flag and segment data. When set, it overrides ``feature_store``, ``update_processor_class``, ``stream``, and ``use_ldd``. diff --git a/ldclient/testing/http_util.py b/ldclient/testing/http_util.py index 95ca4825..ab2a9b38 100644 --- a/ldclient/testing/http_util.py +++ b/ldclient/testing/http_util.py @@ -1,3 +1,4 @@ +import gzip import json import queue import socket @@ -122,7 +123,10 @@ def __init__(self, request): self.headers = request.headers content_length = int(request.headers.get('content-length', 0)) if content_length: - self.body = request.rfile.read(content_length).decode('UTF-8') + raw = request.rfile.read(content_length) + if request.headers.get('content-encoding') == 'gzip': + raw = gzip.decompress(raw) + self.body = raw.decode('UTF-8') else: self.body = None diff --git a/ldclient/testing/impl/events/test_async_event_processor.py b/ldclient/testing/impl/events/test_async_event_processor.py index f6d67632..23a10e5b 100644 --- a/ldclient/testing/impl/events/test_async_event_processor.py +++ b/ldclient/testing/impl/events/test_async_event_processor.py @@ -37,7 +37,7 @@ EventInputIdentify ) from ldclient.testing.builders import FlagBuilder -from ldclient.testing.stub_util import _MockHTTPHeaderDict +from ldclient.testing.stub_util import _MockHTTPHeaderDict, decode_request_body pytestmark = pytest.mark.asyncio @@ -83,6 +83,13 @@ def request(self, method, uri, headers=None, data=None, timeout=None, proxy=None @property def request_data(self): + """Return the body of the last request. Decompress the body if it is gzip-encoded.""" + if len(self._recorded_requests) != 0: + return decode_request_body(*self._recorded_requests[-1]) + + @property + def raw_request_data(self): + """Return the body of the last request as the SDK sent it.""" if len(self._recorded_requests) != 0: return self._recorded_requests[-1][1] @@ -481,15 +488,28 @@ async def event_consumer(): assert had_no_more -async def test_event_payload_is_gzip_compressed_when_enabled(): +async def test_event_payload_is_gzip_compressed_by_default(): mock_http = MockAioHttp() - async with make_processor(mock_http, enable_event_compression=True) as ep: + async with make_processor(mock_http) as ep: ep.send_event(EventInputIdentify(timestamp, context)) ep.flush() await ep._wait_until_inactive() assert mock_http.request_headers.get('Content-Encoding') == 'gzip' - output = json.loads(gzip.decompress(mock_http.request_data)) + output = json.loads(gzip.decompress(mock_http.raw_request_data)) + assert len(output) == 1 + assert output[0]['kind'] == 'identify' + + +async def test_event_payload_is_not_compressed_when_disabled(): + mock_http = MockAioHttp() + async with make_processor(mock_http, enable_event_compression=False) as ep: + ep.send_event(EventInputIdentify(timestamp, context)) + ep.flush() + await ep._wait_until_inactive() + + assert mock_http.request_headers.get('Content-Encoding') is None + output = json.loads(mock_http.raw_request_data) assert len(output) == 1 assert output[0]['kind'] == 'identify' diff --git a/ldclient/testing/impl/events/test_event_processor.py b/ldclient/testing/impl/events/test_event_processor.py index 2c2f462b..dae2f600 100644 --- a/ldclient/testing/impl/events/test_event_processor.py +++ b/ldclient/testing/impl/events/test_event_processor.py @@ -1,3 +1,4 @@ +import gzip import json import time import uuid @@ -765,6 +766,30 @@ def verify_recoverable_http_error(status): assert mock_http.request_data is not None +def test_event_payload_is_gzip_compressed_by_default(): + with DefaultTestProcessor() as ep: + ep.send_event(EventInputIdentify(timestamp, context)) + ep.flush() + ep._wait_until_inactive() + + assert mock_http.request_headers.get('Content-Encoding') == 'gzip' + output = json.loads(gzip.decompress(mock_http.raw_request_data)) + assert len(output) == 1 + assert output[0]['kind'] == 'identify' + + +def test_event_payload_is_not_compressed_when_disabled(): + with DefaultTestProcessor(enable_event_compression=False) as ep: + ep.send_event(EventInputIdentify(timestamp, context)) + ep.flush() + ep._wait_until_inactive() + + assert mock_http.request_headers.get('Content-Encoding') is None + output = json.loads(mock_http.raw_request_data) + assert len(output) == 1 + assert output[0]['kind'] == 'identify' + + def test_event_payload_id_is_sent(): with DefaultEventProcessor(Config(sdk_key='SDK_KEY'), mock_http) as ep: ep.send_event(EventInputIdentify(timestamp, context)) diff --git a/ldclient/testing/stub_util.py b/ldclient/testing/stub_util.py index 7f5f6aab..0bafc058 100644 --- a/ldclient/testing/stub_util.py +++ b/ldclient/testing/stub_util.py @@ -1,3 +1,4 @@ +import gzip import json from email.utils import formatdate @@ -11,6 +12,13 @@ from ldclient.testing.http_util import ChunkedResponse, JsonResponse +def decode_request_body(headers, body): + """Return the request body. Decompress the body if the headers show gzip encoding.""" + if headers is not None and headers.get('Content-Encoding') == 'gzip': + return gzip.decompress(body) + return body + + def item_as_json(item): return item.to_json_dict() if isinstance(item, ModelEntity) else item @@ -141,6 +149,13 @@ def clear(self): @property def request_data(self): + """Return the body of the last request. Decompress the body if it is gzip-encoded.""" + if len(self._recorded_requests) != 0: + return decode_request_body(*self._recorded_requests[-1]) + + @property + def raw_request_data(self): + """Return the body of the last request as the SDK sent it.""" if len(self._recorded_requests) != 0: return self._recorded_requests[-1][1] diff --git a/ldclient/testing/test_config.py b/ldclient/testing/test_config.py index 9595eb0d..6f15d969 100644 --- a/ldclient/testing/test_config.py +++ b/ldclient/testing/test_config.py @@ -72,6 +72,20 @@ def test_a_configured_initial_reconnect_delay_is_reported_as_given(config_class, assert config.initial_reconnect_delay == configured +@pytest.mark.parametrize("config_class", CONFIG_CLASSES, ids=CONFIG_IDS) +def test_event_compression_is_enabled_by_default(config_class): + config = config_class(sdk_key="SDK_KEY") + + assert config.enable_event_compression + + +@pytest.mark.parametrize("config_class", CONFIG_CLASSES, ids=CONFIG_IDS) +def test_event_compression_can_be_disabled(config_class): + config = config_class(sdk_key="SDK_KEY", enable_event_compression=False) + + assert not config.enable_event_compression + + def test_can_set_valid_diagnostic_interval(): config = Config(sdk_key="SDK_KEY", diagnostic_recording_interval=61) assert config.diagnostic_recording_interval == 61