From c61148362f34924af0036eb69a4fd280bae72edd Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 16 Jun 2026 14:46:01 -0400 Subject: [PATCH 1/3] opentelemetry-sdk: add support for file exporter with decl config --- .../sdk/_configuration/_common.py | 24 ++++ .../sdk/_configuration/_logger_provider.py | 31 +++++- .../sdk/_configuration/_meter_provider.py | 46 +++++++- .../sdk/_configuration/_tracer_provider.py | 31 +++++- .../tests/_configuration/test_common.py | 36 ++++++ .../_configuration/test_logger_provider.py | 83 ++++++++++++++ .../_configuration/test_meter_provider.py | 105 ++++++++++++++++++ .../_configuration/test_tracer_provider.py | 86 ++++++++++++++ 8 files changed, 424 insertions(+), 18 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index 65729cbb8e..4949ea455e 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -8,6 +8,7 @@ import logging from collections.abc import Callable from typing import Any, Protocol +from urllib.parse import urlparse from opentelemetry.sdk._configuration._exceptions import ConfigurationError from opentelemetry.util._importlib_metadata import entry_points @@ -194,3 +195,26 @@ def _map_compression( f"Unsupported compression value '{value}'. Supported values: " f"{', '.join(supported_values)}." ) + + +def _parse_otlp_file_output_stream(output_stream: str | None) -> str | None: + """Resolve an output_stream value to a file path, or None for stdout. + + Per the OTel file exporter spec, output_stream is "stdout" (or + None, which means the same), or a "file://" URI giving a path. + """ + if output_stream is None or output_stream == "stdout": + return None + try: + parsed = urlparse(output_stream) + except ValueError as exc: + raise ConfigurationError( + f"Failed to parse output_stream '{output_stream}' for " + f"otlp_file_development exporter: {exc}" + ) from exc + if parsed.scheme == "file": + return parsed.path + raise ConfigurationError( + f"Unsupported output_stream '{output_stream}' for otlp_file_development " + "exporter. Supported values: stdout, file://." + ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py index d3ed82caf3..e2ecdfa3a8 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py @@ -9,12 +9,16 @@ from opentelemetry.sdk._configuration._common import ( _map_compression, _parse_headers, + _parse_otlp_file_output_stream, load_entry_point, ) from opentelemetry.sdk._configuration._exceptions import ConfigurationError from opentelemetry.sdk._configuration.models import ( BatchLogRecordProcessor as BatchLogRecordProcessorConfig, ) +from opentelemetry.sdk._configuration.models import ( + ExperimentalOtlpFileExporter as ExperimentalOtlpFileExporterConfig, +) from opentelemetry.sdk._configuration.models import ( LoggerProvider as LoggerProviderConfig, ) @@ -117,10 +121,30 @@ def _create_otlp_grpc_log_exporter( ) +def _create_otlp_file_development_log_exporter( + config: ExperimentalOtlpFileExporterConfig, +) -> LogRecordExporter: + """Create an OTLP file (JSON Lines) log exporter from config.""" + try: + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.exporter.otlp.json.file._log_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415 + FileLogExporter, + ) + except ImportError as exc: + raise ConfigurationError( + "otlp_file_development log exporter requires 'opentelemetry-exporter-otlp-json-file'. " + "Install it with: pip install opentelemetry-exporter-otlp-json-file" + ) from exc + + path = _parse_otlp_file_output_stream(config.output_stream) + return FileLogExporter(path) if path is not None else FileLogExporter() + + _LOG_EXPORTER_REGISTRY: dict = { "otlp_http": _create_otlp_http_log_exporter, "otlp_grpc": _create_otlp_grpc_log_exporter, "console": lambda _: ConsoleLogRecordExporter(), + "otlp_file_development": _create_otlp_file_development_log_exporter, } @@ -134,11 +158,6 @@ def _create_log_record_exporter( by the @_additional_properties decorator are loaded via the ``opentelemetry_logs_exporter`` entry point group. """ - if config.otlp_file_development is not None: - raise ConfigurationError( - "otlp_file_development log exporter is experimental " - "and not yet supported." - ) for name, factory in _LOG_EXPORTER_REGISTRY.items(): value = getattr(config, name, None) if value is not None: @@ -150,7 +169,7 @@ def _create_log_record_exporter( ) raise ConfigurationError( "No exporter type specified in log record exporter config. " - "Supported types: console, otlp_http, otlp_grpc." + "Supported types: console, otlp_http, otlp_grpc, otlp_file_development." ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py index 1b8bd911de..b8727b0d54 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py @@ -9,6 +9,7 @@ from opentelemetry.sdk._configuration._common import ( _map_compression, _parse_headers, + _parse_otlp_file_output_stream, load_entry_point, ) from opentelemetry.sdk._configuration._exceptions import ConfigurationError @@ -21,6 +22,9 @@ from opentelemetry.sdk._configuration.models import ( ExemplarFilter as ExemplarFilterConfig, ) +from opentelemetry.sdk._configuration.models import ( + ExperimentalOtlpFileMetricExporter as ExperimentalOtlpFileMetricExporterConfig, +) from opentelemetry.sdk._configuration.models import ( ExperimentalPrometheusMetricExporter as PrometheusMetricExporterConfig, ) @@ -339,10 +343,45 @@ def _create_otlp_grpc_metric_exporter( ) +def _create_otlp_file_development_metric_exporter( + config: ExperimentalOtlpFileMetricExporterConfig, +) -> MetricExporter: + """Create an OTLP file (JSON Lines) metric exporter from config.""" + try: + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.exporter.otlp.json.file.metric_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415 + FileMetricExporter, + ) + except ImportError as exc: + raise ConfigurationError( + "otlp_file_development metric exporter requires 'opentelemetry-exporter-otlp-json-file'. " + "Install it with: pip install opentelemetry-exporter-otlp-json-file" + ) from exc + + path = _parse_otlp_file_output_stream(config.output_stream) + preferred_temporality = _map_temporality(config.temporality_preference) + preferred_aggregation = _map_histogram_aggregation( + config.default_histogram_aggregation + ) + return ( + FileMetricExporter( + path, + preferred_temporality=preferred_temporality, + preferred_aggregation=preferred_aggregation, + ) + if path is not None + else FileMetricExporter( + preferred_temporality=preferred_temporality, + preferred_aggregation=preferred_aggregation, + ) + ) + + _METRIC_EXPORTER_REGISTRY: dict = { "otlp_http": _create_otlp_http_metric_exporter, "otlp_grpc": _create_otlp_grpc_metric_exporter, "console": _create_console_metric_exporter, + "otlp_file_development": _create_otlp_file_development_metric_exporter, } @@ -356,11 +395,6 @@ def _create_push_metric_exporter( by the @_additional_properties decorator are loaded via the ``opentelemetry_metrics_exporter`` entry point group. """ - if config.otlp_file_development is not None: - raise ConfigurationError( - "otlp_file_development metric exporter is experimental " - "and not yet supported." - ) for name, factory in _METRIC_EXPORTER_REGISTRY.items(): value = getattr(config, name, None) if value is not None: @@ -372,7 +406,7 @@ def _create_push_metric_exporter( ) raise ConfigurationError( "No exporter type specified in push metric exporter config. " - "Supported types: console, otlp_http, otlp_grpc." + "Supported types: console, otlp_http, otlp_grpc, otlp_file_development." ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py index b609d16be8..9cb742b8d1 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -9,9 +9,13 @@ from opentelemetry.sdk._configuration._common import ( _map_compression, _parse_headers, + _parse_otlp_file_output_stream, load_entry_point, ) from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + ExperimentalOtlpFileExporter as ExperimentalOtlpFileExporterConfig, +) from opentelemetry.sdk._configuration.models import ( OtlpGrpcExporter as OtlpGrpcExporterConfig, ) @@ -127,10 +131,30 @@ def _create_otlp_grpc_span_exporter( ) +def _create_otlp_file_development_span_exporter( + config: ExperimentalOtlpFileExporterConfig, +) -> SpanExporter: + """Create an OTLP file (JSON Lines) span exporter from config.""" + try: + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.exporter.otlp.json.file.trace_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415 + FileSpanExporter, + ) + except ImportError as exc: + raise ConfigurationError( + "otlp_file_development span exporter requires 'opentelemetry-exporter-otlp-json-file'. " + "Install it with: pip install opentelemetry-exporter-otlp-json-file" + ) from exc + + path = _parse_otlp_file_output_stream(config.output_stream) + return FileSpanExporter(path) if path is not None else FileSpanExporter() + + _SPAN_EXPORTER_REGISTRY: dict = { "otlp_http": _create_otlp_http_span_exporter, "otlp_grpc": _create_otlp_grpc_span_exporter, "console": lambda _: ConsoleSpanExporter(), + "otlp_file_development": _create_otlp_file_development_span_exporter, } @@ -142,11 +166,6 @@ def _create_span_exporter(config: SpanExporterConfig) -> SpanExporter: by the @_additional_properties decorator are loaded via the ``opentelemetry_traces_exporter`` entry point group. """ - if config.otlp_file_development is not None: - raise ConfigurationError( - "otlp_file_development span exporter is experimental " - "and not yet supported." - ) for name, factory in _SPAN_EXPORTER_REGISTRY.items(): value = getattr(config, name, None) if value is not None: @@ -158,7 +177,7 @@ def _create_span_exporter(config: SpanExporterConfig) -> SpanExporter: ) raise ConfigurationError( "No exporter type specified in span exporter config. " - "Supported types: otlp_http, otlp_grpc, console." + "Supported types: otlp_http, otlp_grpc, console, otlp_file_development." ) diff --git a/opentelemetry-sdk/tests/_configuration/test_common.py b/opentelemetry-sdk/tests/_configuration/test_common.py index 46469f15d2..02d1a8c5ac 100644 --- a/opentelemetry-sdk/tests/_configuration/test_common.py +++ b/opentelemetry-sdk/tests/_configuration/test_common.py @@ -12,6 +12,7 @@ _additional_properties, _map_compression, _parse_headers, + _parse_otlp_file_output_stream, _resolve_component, load_entry_point, ) @@ -206,6 +207,41 @@ def test_http_error_message_includes_deflate(self): ) +class TestParseOtlpFileOutputStream(unittest.TestCase): + def test_none_returns_none(self): + self.assertIsNone(_parse_otlp_file_output_stream(None)) + + def test_stdout_returns_none(self): + self.assertIsNone(_parse_otlp_file_output_stream("stdout")) + + def test_file_uri_returns_path(self): + self.assertEqual( + _parse_otlp_file_output_stream("file:///tmp/traces.jsonl"), + "/tmp/traces.jsonl", + ) + + def test_unsupported_scheme_raises(self): + with self.assertRaises(ConfigurationError) as ctx: + _parse_otlp_file_output_stream("http://example") + + self.assertEqual( + str(ctx.exception), + "Unsupported output_stream 'http://example' for " + "otlp_file_development exporter. Supported values: stdout, " + "file://.", + ) + + def test_malformed_uri_raises_configuration_error(self): + with self.assertRaises(ConfigurationError) as ctx: + _parse_otlp_file_output_stream("file://[::1") + + self.assertIn( + "Failed to parse output_stream 'file://[::1' for " + "otlp_file_development exporter", + str(ctx.exception), + ) + + class TestAdditionalPropertiesSupport(unittest.TestCase): def setUp(self): @_additional_properties diff --git a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py index 3e2185327d..1835880cf3 100644 --- a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py @@ -25,6 +25,9 @@ from opentelemetry.sdk._configuration.models import ( BatchLogRecordProcessor as BatchLogRecordProcessorConfig, ) +from opentelemetry.sdk._configuration.models import ( + ExperimentalOtlpFileExporter as ExperimentalOtlpFileExporterConfig, +) from opentelemetry.sdk._configuration.models import ( LoggerProvider as LoggerProviderConfig, ) @@ -274,6 +277,86 @@ def test_otlp_grpc_missing_package_raises(self): with self.assertRaises(ConfigurationError): _create_log_record_exporter(config) + def test_otlp_file_development_missing_package_raises(self): + config = LogRecordExporterConfig( + otlp_file_development=ExperimentalOtlpFileExporterConfig() + ) + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.json.file._log_exporter": None, + }, + ): + with self.assertRaises(ConfigurationError) as ctx: + _create_log_record_exporter(config) + self.assertIn( + "opentelemetry-exporter-otlp-json-file", str(ctx.exception) + ) + + def test_otlp_file_development_default_stdout(self): + mock_exporter_cls = MagicMock() + mock_module = MagicMock() + mock_module.FileLogExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.json.file._log_exporter": mock_module, + }, + ): + config = LogRecordExporterConfig( + otlp_file_development=ExperimentalOtlpFileExporterConfig() + ) + _create_log_record_exporter(config) + + mock_exporter_cls.assert_called_once() + self.assertEqual(mock_exporter_cls.call_args.args, ()) + self.assertEqual(mock_exporter_cls.call_args.kwargs, {}) + + def test_otlp_file_development_file_uri(self): + mock_exporter_cls = MagicMock() + mock_module = MagicMock() + mock_module.FileLogExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.json.file._log_exporter": mock_module, + }, + ): + config = LogRecordExporterConfig( + otlp_file_development=ExperimentalOtlpFileExporterConfig( + output_stream="file:///tmp/logs.jsonl" + ) + ) + _create_log_record_exporter(config) + + mock_exporter_cls.assert_called_once() + self.assertEqual( + mock_exporter_cls.call_args.args, ("/tmp/logs.jsonl",) + ) + + def test_otlp_file_development_unsupported_output_stream_raises(self): + mock_exporter_cls = MagicMock() + mock_module = MagicMock() + mock_module.FileLogExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.json.file._log_exporter": mock_module, + }, + ): + config = LogRecordExporterConfig( + otlp_file_development=ExperimentalOtlpFileExporterConfig( + output_stream="http://example" + ) + ) + with self.assertRaises(ConfigurationError) as ctx: + _create_log_record_exporter(config) + self.assertIn("output_stream", str(ctx.exception)) + mock_exporter_cls.assert_not_called() + def test_otlp_http_exporter_endpoint(self): mock_exporter_cls = MagicMock() mock_compression_cls = MagicMock() diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index 566329cf14..19f32a6996 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -26,6 +26,9 @@ from opentelemetry.sdk._configuration.models import ( ExemplarFilter as ExemplarFilterConfig, ) +from opentelemetry.sdk._configuration.models import ( + ExperimentalOtlpFileMetricExporter as ExperimentalOtlpFileMetricExporterConfig, +) from opentelemetry.sdk._configuration.models import ( ExperimentalPrometheusMetricExporter as PrometheusMetricExporterConfig, ) @@ -310,6 +313,108 @@ def test_otlp_grpc_missing_package_raises(self): create_meter_provider(config) self.assertIn("otlp-proto-grpc", str(ctx.exception)) + def test_otlp_file_development_missing_package_raises(self): + config = self._make_periodic_config( + PushMetricExporterConfig( + otlp_file_development=ExperimentalOtlpFileMetricExporterConfig() + ) + ) + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.json.file.metric_exporter": None, + }, + ): + with self.assertRaises(ConfigurationError) as ctx: + create_meter_provider(config) + self.assertIn( + "opentelemetry-exporter-otlp-json-file", str(ctx.exception) + ) + + def test_otlp_file_development_default_stdout(self): + mock_exporter_cls = MagicMock() + mock_module = MagicMock() + mock_module.FileMetricExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.json.file.metric_exporter": mock_module, + }, + ): + config = self._make_periodic_config( + PushMetricExporterConfig( + otlp_file_development=ExperimentalOtlpFileMetricExporterConfig() + ) + ) + create_meter_provider(config) + + args, kwargs = mock_exporter_cls.call_args + self.assertEqual(args, ()) + self.assertEqual( + kwargs["preferred_temporality"], + { + Counter: AggregationTemporality.CUMULATIVE, + UpDownCounter: AggregationTemporality.CUMULATIVE, + Histogram: AggregationTemporality.CUMULATIVE, + ObservableCounter: AggregationTemporality.CUMULATIVE, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, + }, + ) + self.assertIsInstance( + kwargs["preferred_aggregation"][Histogram], + ExplicitBucketHistogramAggregation, + ) + + def test_otlp_file_development_file_uri(self): + mock_exporter_cls = MagicMock() + mock_module = MagicMock() + mock_module.FileMetricExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.json.file.metric_exporter": mock_module, + }, + ): + config = self._make_periodic_config( + PushMetricExporterConfig( + otlp_file_development=ExperimentalOtlpFileMetricExporterConfig( + output_stream="file:///tmp/metrics.jsonl" + ) + ) + ) + create_meter_provider(config) + + args, kwargs = mock_exporter_cls.call_args + self.assertEqual(args, ("/tmp/metrics.jsonl",)) + self.assertIn("preferred_temporality", kwargs) + self.assertIn("preferred_aggregation", kwargs) + + def test_otlp_file_development_unsupported_output_stream_raises(self): + mock_exporter_cls = MagicMock() + mock_module = MagicMock() + mock_module.FileMetricExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.json.file.metric_exporter": mock_module, + }, + ): + config = self._make_periodic_config( + PushMetricExporterConfig( + otlp_file_development=ExperimentalOtlpFileMetricExporterConfig( + output_stream="http://example" + ) + ) + ) + with self.assertRaises(ConfigurationError) as ctx: + create_meter_provider(config) + self.assertIn("output_stream", str(ctx.exception)) + mock_exporter_cls.assert_not_called() + class TestCreatePullMetricReaders(unittest.TestCase): def test_pull_prometheus_creates_reader(self): diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index 2c73ee3649..0e522450de 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -17,6 +17,9 @@ from opentelemetry.sdk._configuration.models import ( BatchSpanProcessor as BatchSpanProcessorConfig, ) +from opentelemetry.sdk._configuration.models import ( + ExperimentalOtlpFileExporter as ExperimentalOtlpFileExporterConfig, +) from opentelemetry.sdk._configuration.models import ( OtlpGrpcExporter as OtlpGrpcExporterConfig, ) @@ -370,6 +373,89 @@ def test_otlp_http_headers_list(self): kwargs["headers"], {"x-api-key": "secret", "env": "prod"} ) + def test_otlp_file_development_missing_package_raises(self): + config = self._make_batch_config( + SpanExporterConfig( + otlp_file_development=ExperimentalOtlpFileExporterConfig() + ) + ) + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.json.file.trace_exporter": None, + }, + ): + with self.assertRaises(ConfigurationError) as ctx: + create_tracer_provider(config) + self.assertIn( + "opentelemetry-exporter-otlp-json-file", str(ctx.exception) + ) + + def test_otlp_file_development_default_stdout(self): + mock_exporter_cls = MagicMock() + mock_module = MagicMock() + mock_module.FileSpanExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.json.file.trace_exporter": mock_module, + }, + ): + config = self._make_batch_config( + SpanExporterConfig( + otlp_file_development=ExperimentalOtlpFileExporterConfig() + ) + ) + create_tracer_provider(config) + + mock_exporter_cls.assert_called_once_with() + + def test_otlp_file_development_file_uri(self): + mock_exporter_cls = MagicMock() + mock_module = MagicMock() + mock_module.FileSpanExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.json.file.trace_exporter": mock_module, + }, + ): + config = self._make_batch_config( + SpanExporterConfig( + otlp_file_development=ExperimentalOtlpFileExporterConfig( + output_stream="file:///tmp/traces.jsonl" + ) + ) + ) + create_tracer_provider(config) + + mock_exporter_cls.assert_called_once_with("/tmp/traces.jsonl") + + def test_otlp_file_development_unsupported_output_stream_raises(self): + mock_exporter_cls = MagicMock() + mock_module = MagicMock() + mock_module.FileSpanExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.json.file.trace_exporter": mock_module, + }, + ): + config = self._make_batch_config( + SpanExporterConfig( + otlp_file_development=ExperimentalOtlpFileExporterConfig( + output_stream="http://example" + ) + ) + ) + with self.assertRaises(ConfigurationError) as ctx: + create_tracer_provider(config) + self.assertIn("output_stream", str(ctx.exception)) + mock_exporter_cls.assert_not_called() + def test_otlp_grpc_missing_package_raises(self): config = self._make_batch_config( SpanExporterConfig(otlp_grpc=OtlpGrpcExporterConfig()) From c40fb7fe7dc6ecef5d30cf01bb2d9289a2b37754 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 16 Jun 2026 14:47:21 -0400 Subject: [PATCH 2/3] add changelog fragment --- .changelog/5311.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/5311.added diff --git a/.changelog/5311.added b/.changelog/5311.added new file mode 100644 index 0000000000..25641d282d --- /dev/null +++ b/.changelog/5311.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: add support for file exporter with declarative config From 74df18459a943c2d75dfcff63459cb8f1345b517 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 16 Jun 2026 15:05:00 -0400 Subject: [PATCH 3/3] make _parse_otlp_file_output_stream() more robust --- .../sdk/_configuration/_common.py | 8 ++++- .../tests/_configuration/test_common.py | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index 4949ea455e..33d0f38803 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -212,7 +212,13 @@ def _parse_otlp_file_output_stream(output_stream: str | None) -> str | None: f"Failed to parse output_stream '{output_stream}' for " f"otlp_file_development exporter: {exc}" ) from exc - if parsed.scheme == "file": + is_local_file_uri = ( + parsed.scheme == "file" + and parsed.netloc in ("", "localhost") + and bool(parsed.path) + ) + has_extra_components = parsed.params or parsed.query or parsed.fragment + if is_local_file_uri and not has_extra_components: return parsed.path raise ConfigurationError( f"Unsupported output_stream '{output_stream}' for otlp_file_development " diff --git a/opentelemetry-sdk/tests/_configuration/test_common.py b/opentelemetry-sdk/tests/_configuration/test_common.py index 02d1a8c5ac..ebe78b787c 100644 --- a/opentelemetry-sdk/tests/_configuration/test_common.py +++ b/opentelemetry-sdk/tests/_configuration/test_common.py @@ -220,6 +220,37 @@ def test_file_uri_returns_path(self): "/tmp/traces.jsonl", ) + def test_file_uri_localhost_host_returns_path(self): + self.assertEqual( + _parse_otlp_file_output_stream( + "file://localhost/tmp/traces.jsonl" + ), + "/tmp/traces.jsonl", + ) + + def test_file_uri_with_other_host_raises(self): + with self.assertRaises(ConfigurationError) as ctx: + _parse_otlp_file_output_stream("file://otherhost/tmp/traces.jsonl") + + self.assertEqual( + str(ctx.exception), + "Unsupported output_stream 'file://otherhost/tmp/traces.jsonl' " + "for otlp_file_development exporter. Supported values: stdout, " + "file://.", + ) + + def test_file_uri_empty_path_raises(self): + with self.assertRaises(ConfigurationError): + _parse_otlp_file_output_stream("file://") + + def test_file_uri_with_query_raises(self): + with self.assertRaises(ConfigurationError): + _parse_otlp_file_output_stream("file:///tmp/traces.jsonl?foo=bar") + + def test_file_uri_with_fragment_raises(self): + with self.assertRaises(ConfigurationError): + _parse_otlp_file_output_stream("file:///tmp/traces.jsonl#frag") + def test_unsupported_scheme_raises(self): with self.assertRaises(ConfigurationError) as ctx: _parse_otlp_file_output_stream("http://example")