From 80ca78e192d26a1d076f8fd43ea81aec8b809e98 Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Thu, 9 Apr 2026 18:05:52 +0000 Subject: [PATCH] fix(config): allow deflate for OTLP HTTP exporters Map declarative OTLP compression values through a shared helper that recognizes Deflate when the exporter enum supports it, while leaving gRPC validation unchanged. Add regression coverage for the shared mapping helper plus tracer and meter provider HTTP exporter construction. --- CHANGELOG.md | 2 + .../sdk/_configuration/_common.py | 23 ++++++++ .../sdk/_configuration/_meter_provider.py | 22 ++------ .../sdk/_configuration/_tracer_provider.py | 19 ++----- .../tests/_configuration/test_common.py | 56 ++++++++++++++++++- .../_configuration/test_meter_provider.py | 28 ++++++++++ .../_configuration/test_tracer_provider.py | 26 +++++++++ 7 files changed, 144 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f850a5f966..7888809992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `opentelemetry-sdk`: Allow declarative OTLP HTTP exporters to map `compression: deflate` instead of rejecting it as unsupported + ## Version 1.41.0/0.62b0 (2026-04-09) - `opentelemetry-sdk`: Add `host` resource detector support to declarative file configuration via `detection_development.detectors[].host` diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index 152be1ea01..4676055c9c 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -17,6 +17,8 @@ import logging from typing import Optional +from opentelemetry.sdk._configuration._exceptions import ConfigurationError + _logger = logging.getLogger(__name__) @@ -47,3 +49,24 @@ def _parse_headers( for pair in headers: result[pair.name] = pair.value or "" return result + + +def _map_compression( + value: Optional[str], compression_enum: type +) -> Optional[object]: + """Map a compression string to the given Compression enum value.""" + if value is None or value.lower() == "none": + return None + if value.lower() == "gzip": + return compression_enum.Gzip # type: ignore[attr-defined] + if value.lower() == "deflate" and hasattr(compression_enum, "Deflate"): + return compression_enum.Deflate # type: ignore[attr-defined] + + supported_values = ["'gzip'", "'none'"] + if hasattr(compression_enum, "Deflate"): + supported_values.insert(1, "'deflate'") + + raise ConfigurationError( + f"Unsupported compression value '{value}'. Supported values: " + f"{', '.join(supported_values)}." + ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py index 257351135f..73a17d0c8e 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py @@ -18,7 +18,10 @@ from typing import Optional, Set, Type from opentelemetry import metrics -from opentelemetry.sdk._configuration._common import _parse_headers +from opentelemetry.sdk._configuration._common import ( + _map_compression, + _parse_headers, +) from opentelemetry.sdk._configuration._exceptions import ConfigurationError from opentelemetry.sdk._configuration.models import ( Aggregation as AggregationConfig, @@ -265,19 +268,6 @@ def _create_console_metric_exporter( ) -def _map_compression_metric( - value: Optional[str], compression_enum: type -) -> Optional[object]: - """Map a compression string to the given Compression enum value.""" - if value is None or value.lower() == "none": - return None - if value.lower() == "gzip": - return compression_enum.Gzip # type: ignore[attr-defined] - raise ConfigurationError( - f"Unsupported compression value '{value}'. Supported values: 'gzip', 'none'." - ) - - def _create_otlp_http_metric_exporter( config: OtlpHttpMetricExporterConfig, ) -> MetricExporter: @@ -296,7 +286,7 @@ def _create_otlp_http_metric_exporter( "Install it with: pip install opentelemetry-exporter-otlp-proto-http" ) from exc - compression = _map_compression_metric(config.compression, Compression) + compression = _map_compression(config.compression, Compression) headers = _parse_headers(config.headers, config.headers_list) timeout = (config.timeout / 1000.0) if config.timeout is not None else None preferred_temporality = _map_temporality(config.temporality_preference) @@ -331,7 +321,7 @@ def _create_otlp_grpc_metric_exporter( "Install it with: pip install opentelemetry-exporter-otlp-proto-grpc" ) from exc - compression = _map_compression_metric(config.compression, grpc.Compression) + compression = _map_compression(config.compression, grpc.Compression) headers = _parse_headers(config.headers, config.headers_list) timeout = (config.timeout / 1000.0) if config.timeout is not None else None preferred_temporality = _map_temporality(config.temporality_preference) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py index 32dfd96567..273e3d3bd7 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -18,7 +18,10 @@ from typing import Optional from opentelemetry import trace -from opentelemetry.sdk._configuration._common import _parse_headers +from opentelemetry.sdk._configuration._common import ( + _map_compression, + _parse_headers, +) from opentelemetry.sdk._configuration._exceptions import ConfigurationError from opentelemetry.sdk._configuration.models import ( OtlpGrpcExporter as OtlpGrpcExporterConfig, @@ -103,20 +106,6 @@ def _create_otlp_http_span_exporter( compression=compression, # type: ignore[arg-type] ) - -def _map_compression( - value: Optional[str], compression_enum: type -) -> Optional[object]: - """Map a compression string to the given Compression enum value.""" - if value is None or value.lower() == "none": - return None - if value.lower() == "gzip": - return compression_enum.Gzip # type: ignore[attr-defined] - raise ConfigurationError( - f"Unsupported compression value '{value}'. Supported values: 'gzip', 'none'." - ) - - def _create_otlp_grpc_span_exporter( config: OtlpGrpcExporterConfig, ) -> SpanExporter: diff --git a/opentelemetry-sdk/tests/_configuration/test_common.py b/opentelemetry-sdk/tests/_configuration/test_common.py index 5c3fcf112b..e9d3c0bd1e 100644 --- a/opentelemetry-sdk/tests/_configuration/test_common.py +++ b/opentelemetry-sdk/tests/_configuration/test_common.py @@ -15,7 +15,11 @@ import unittest from types import SimpleNamespace -from opentelemetry.sdk._configuration._common import _parse_headers +from opentelemetry.sdk._configuration._common import ( + _map_compression, + _parse_headers, +) +from opentelemetry.sdk._configuration._exceptions import ConfigurationError class TestParseHeaders(unittest.TestCase): @@ -79,3 +83,53 @@ def test_struct_headers_override_headers_list(self): def test_both_empty_struct_and_none_list_returns_empty_dict(self): self.assertEqual(_parse_headers([], None), {}) + + +class _CompressionWithDeflate: + Gzip = "gzip" + Deflate = "deflate" + + +class _CompressionWithoutDeflate: + Gzip = "gzip" + + +class TestMapCompression(unittest.TestCase): + def test_none_returns_none(self): + self.assertIsNone(_map_compression(None, _CompressionWithDeflate)) + + def test_none_string_returns_none(self): + self.assertIsNone( + _map_compression("none", _CompressionWithDeflate) + ) + + def test_gzip_maps_to_gzip(self): + self.assertEqual( + _map_compression("gzip", _CompressionWithDeflate), "gzip" + ) + + def test_deflate_maps_when_supported(self): + self.assertEqual( + _map_compression("deflate", _CompressionWithDeflate), + "deflate", + ) + + def test_deflate_raises_when_unsupported(self): + with self.assertRaises(ConfigurationError) as ctx: + _map_compression("deflate", _CompressionWithoutDeflate) + + self.assertEqual( + str(ctx.exception), + "Unsupported compression value 'deflate'. Supported values: " + "'gzip', 'none'.", + ) + + def test_http_error_message_includes_deflate(self): + with self.assertRaises(ConfigurationError) as ctx: + _map_compression("brotli", _CompressionWithDeflate) + + self.assertEqual( + str(ctx.exception), + "Unsupported compression value 'brotli'. Supported values: " + "'gzip', 'deflate', 'none'.", + ) diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index 04d60847f0..10e7dededb 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -274,6 +274,34 @@ def test_otlp_http_created_with_endpoint(self): self.assertIsNone(kwargs["timeout"]) self.assertIsNone(kwargs["compression"]) + def test_otlp_http_created_with_deflate_compression(self): + mock_exporter_cls = MagicMock() + mock_compression_cls = MagicMock() + mock_compression_cls.Deflate = "deflate_val" + mock_http_module = MagicMock() + mock_http_module.Compression = mock_compression_cls + mock_module = MagicMock() + mock_module.OTLPMetricExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http.metric_exporter": mock_module, + "opentelemetry.exporter.otlp.proto.http": mock_http_module, + }, + ): + config = self._make_periodic_config( + PushMetricExporterConfig( + otlp_http=OtlpHttpMetricExporterConfig( + compression="deflate" + ) + ) + ) + create_meter_provider(config) + + _, kwargs = mock_exporter_cls.call_args + self.assertEqual(kwargs["compression"], "deflate_val") + def test_otlp_grpc_missing_package_raises(self): config = self._make_periodic_config( PushMetricExporterConfig(otlp_grpc=OtlpGrpcMetricExporterConfig()) diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index 5caf077cd5..05859d3323 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -309,6 +309,32 @@ def test_otlp_http_created_with_endpoint(self): compression=None, ) + def test_otlp_http_created_with_deflate_compression(self): + mock_exporter_cls = MagicMock() + mock_compression_cls = MagicMock() + mock_compression_cls.Deflate = "deflate_val" + mock_module = MagicMock() + mock_module.OTLPSpanExporter = mock_exporter_cls + mock_http_module = MagicMock() + mock_http_module.Compression = mock_compression_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http.trace_exporter": mock_module, + "opentelemetry.exporter.otlp.proto.http": mock_http_module, + }, + ): + config = self._make_batch_config( + SpanExporterConfig( + otlp_http=OtlpHttpExporterConfig(compression="deflate") + ) + ) + create_tracer_provider(config) + + _, kwargs = mock_exporter_cls.call_args + self.assertEqual(kwargs["compression"], "deflate_val") + def test_otlp_http_headers_list(self): mock_exporter_cls = MagicMock() mock_http_module = MagicMock()