Skip to content

Commit 50912be

Browse files
authored
opentelemetry-sdk: add support for file exporter with declarative config (open-telemetry#5311)
* opentelemetry-sdk: add support for file exporter with decl config * add changelog fragment * make _parse_otlp_file_output_stream() more robust * make _parse_otlp_file_output_stream() more robust to user inputs * fix Windows test failures * fix tests
1 parent 0a59adf commit 50912be

9 files changed

Lines changed: 495 additions & 18 deletions

File tree

.changelog/5311.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-sdk`: add support for file exporter with declarative config

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import logging
99
from collections.abc import Callable
1010
from typing import Any, Protocol
11+
from urllib.parse import urlparse
1112

1213
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
1314
from opentelemetry.util._importlib_metadata import entry_points
@@ -194,3 +195,44 @@ def _map_compression(
194195
f"Unsupported compression value '{value}'. Supported values: "
195196
f"{', '.join(supported_values)}."
196197
)
198+
199+
200+
def _parse_otlp_file_output_stream(output_stream: str | None) -> str | None:
201+
"""Resolve an output_stream value to a file path, or None for stdout.
202+
203+
Per the OTel file exporter spec, output_stream is "stdout" (or
204+
None, which means the same), or a "file://" URI giving a path.
205+
"""
206+
if output_stream is None or output_stream == "stdout":
207+
return None
208+
try:
209+
parsed = urlparse(output_stream)
210+
except ValueError as exc:
211+
raise ConfigurationError(
212+
f"Failed to parse output_stream '{output_stream}' for "
213+
f"otlp_file_development exporter: {exc}"
214+
) from exc
215+
is_local_file_uri = (
216+
parsed.scheme == "file"
217+
and parsed.netloc in ("", "localhost")
218+
and bool(parsed.path)
219+
)
220+
has_extra_components = parsed.params or parsed.query or parsed.fragment
221+
if is_local_file_uri and not has_extra_components:
222+
path = parsed.path
223+
if not path.startswith("/"):
224+
raise ConfigurationError(
225+
f"Unsupported output_stream '{output_stream}' for "
226+
"otlp_file_development exporter. Path must be absolute."
227+
)
228+
if path.endswith("/"):
229+
raise ConfigurationError(
230+
f"Unsupported output_stream '{output_stream}' for "
231+
"otlp_file_development exporter. Path must be a file, "
232+
"not a directory."
233+
)
234+
return path
235+
raise ConfigurationError(
236+
f"Unsupported output_stream '{output_stream}' for otlp_file_development "
237+
"exporter. Supported values: stdout, file://<path>."
238+
)

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@
99
from opentelemetry.sdk._configuration._common import (
1010
_map_compression,
1111
_parse_headers,
12+
_parse_otlp_file_output_stream,
1213
load_entry_point,
1314
)
1415
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
1516
from opentelemetry.sdk._configuration.models import (
1617
BatchLogRecordProcessor as BatchLogRecordProcessorConfig,
1718
)
19+
from opentelemetry.sdk._configuration.models import (
20+
ExperimentalOtlpFileExporter as ExperimentalOtlpFileExporterConfig,
21+
)
1822
from opentelemetry.sdk._configuration.models import (
1923
LoggerProvider as LoggerProviderConfig,
2024
)
@@ -117,10 +121,30 @@ def _create_otlp_grpc_log_exporter(
117121
)
118122

119123

124+
def _create_otlp_file_development_log_exporter(
125+
config: ExperimentalOtlpFileExporterConfig,
126+
) -> LogRecordExporter:
127+
"""Create an OTLP file (JSON Lines) log exporter from config."""
128+
try:
129+
# pylint: disable=import-outside-toplevel,no-name-in-module
130+
from opentelemetry.exporter.otlp.json.file._log_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415
131+
FileLogExporter,
132+
)
133+
except ImportError as exc:
134+
raise ConfigurationError(
135+
"otlp_file_development log exporter requires 'opentelemetry-exporter-otlp-json-file'. "
136+
"Install it with: pip install opentelemetry-exporter-otlp-json-file"
137+
) from exc
138+
139+
path = _parse_otlp_file_output_stream(config.output_stream)
140+
return FileLogExporter(path) if path is not None else FileLogExporter()
141+
142+
120143
_LOG_EXPORTER_REGISTRY: dict = {
121144
"otlp_http": _create_otlp_http_log_exporter,
122145
"otlp_grpc": _create_otlp_grpc_log_exporter,
123146
"console": lambda _: ConsoleLogRecordExporter(),
147+
"otlp_file_development": _create_otlp_file_development_log_exporter,
124148
}
125149

126150

@@ -134,11 +158,6 @@ def _create_log_record_exporter(
134158
by the @_additional_properties decorator are loaded via the
135159
``opentelemetry_logs_exporter`` entry point group.
136160
"""
137-
if config.otlp_file_development is not None:
138-
raise ConfigurationError(
139-
"otlp_file_development log exporter is experimental "
140-
"and not yet supported."
141-
)
142161
for name, factory in _LOG_EXPORTER_REGISTRY.items():
143162
value = getattr(config, name, None)
144163
if value is not None:
@@ -150,7 +169,7 @@ def _create_log_record_exporter(
150169
)
151170
raise ConfigurationError(
152171
"No exporter type specified in log record exporter config. "
153-
"Supported types: console, otlp_http, otlp_grpc."
172+
"Supported types: console, otlp_http, otlp_grpc, otlp_file_development."
154173
)
155174

156175

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from opentelemetry.sdk._configuration._common import (
1010
_map_compression,
1111
_parse_headers,
12+
_parse_otlp_file_output_stream,
1213
load_entry_point,
1314
)
1415
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
@@ -21,6 +22,9 @@
2122
from opentelemetry.sdk._configuration.models import (
2223
ExemplarFilter as ExemplarFilterConfig,
2324
)
25+
from opentelemetry.sdk._configuration.models import (
26+
ExperimentalOtlpFileMetricExporter as ExperimentalOtlpFileMetricExporterConfig,
27+
)
2428
from opentelemetry.sdk._configuration.models import (
2529
ExperimentalPrometheusMetricExporter as PrometheusMetricExporterConfig,
2630
)
@@ -339,10 +343,45 @@ def _create_otlp_grpc_metric_exporter(
339343
)
340344

341345

346+
def _create_otlp_file_development_metric_exporter(
347+
config: ExperimentalOtlpFileMetricExporterConfig,
348+
) -> MetricExporter:
349+
"""Create an OTLP file (JSON Lines) metric exporter from config."""
350+
try:
351+
# pylint: disable=import-outside-toplevel,no-name-in-module
352+
from opentelemetry.exporter.otlp.json.file.metric_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415
353+
FileMetricExporter,
354+
)
355+
except ImportError as exc:
356+
raise ConfigurationError(
357+
"otlp_file_development metric exporter requires 'opentelemetry-exporter-otlp-json-file'. "
358+
"Install it with: pip install opentelemetry-exporter-otlp-json-file"
359+
) from exc
360+
361+
path = _parse_otlp_file_output_stream(config.output_stream)
362+
preferred_temporality = _map_temporality(config.temporality_preference)
363+
preferred_aggregation = _map_histogram_aggregation(
364+
config.default_histogram_aggregation
365+
)
366+
return (
367+
FileMetricExporter(
368+
path,
369+
preferred_temporality=preferred_temporality,
370+
preferred_aggregation=preferred_aggregation,
371+
)
372+
if path is not None
373+
else FileMetricExporter(
374+
preferred_temporality=preferred_temporality,
375+
preferred_aggregation=preferred_aggregation,
376+
)
377+
)
378+
379+
342380
_METRIC_EXPORTER_REGISTRY: dict = {
343381
"otlp_http": _create_otlp_http_metric_exporter,
344382
"otlp_grpc": _create_otlp_grpc_metric_exporter,
345383
"console": _create_console_metric_exporter,
384+
"otlp_file_development": _create_otlp_file_development_metric_exporter,
346385
}
347386

348387

@@ -356,11 +395,6 @@ def _create_push_metric_exporter(
356395
by the @_additional_properties decorator are loaded via the
357396
``opentelemetry_metrics_exporter`` entry point group.
358397
"""
359-
if config.otlp_file_development is not None:
360-
raise ConfigurationError(
361-
"otlp_file_development metric exporter is experimental "
362-
"and not yet supported."
363-
)
364398
for name, factory in _METRIC_EXPORTER_REGISTRY.items():
365399
value = getattr(config, name, None)
366400
if value is not None:
@@ -372,7 +406,7 @@ def _create_push_metric_exporter(
372406
)
373407
raise ConfigurationError(
374408
"No exporter type specified in push metric exporter config. "
375-
"Supported types: console, otlp_http, otlp_grpc."
409+
"Supported types: console, otlp_http, otlp_grpc, otlp_file_development."
376410
)
377411

378412

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from opentelemetry.sdk._configuration._common import (
1010
_map_compression,
1111
_parse_headers,
12+
_parse_otlp_file_output_stream,
1213
load_entry_point,
1314
)
1415
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
@@ -21,6 +22,9 @@
2122
from opentelemetry.sdk._configuration.models import (
2223
ExperimentalComposableSampler as ComposableSamplerConfig,
2324
)
25+
from opentelemetry.sdk._configuration.models import (
26+
ExperimentalOtlpFileExporter as ExperimentalOtlpFileExporterConfig,
27+
)
2428
from opentelemetry.sdk._configuration.models import (
2529
OtlpGrpcExporter as OtlpGrpcExporterConfig,
2630
)
@@ -156,10 +160,30 @@ def _create_otlp_grpc_span_exporter(
156160
)
157161

158162

163+
def _create_otlp_file_development_span_exporter(
164+
config: ExperimentalOtlpFileExporterConfig,
165+
) -> SpanExporter:
166+
"""Create an OTLP file (JSON Lines) span exporter from config."""
167+
try:
168+
# pylint: disable=import-outside-toplevel,no-name-in-module
169+
from opentelemetry.exporter.otlp.json.file.trace_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415
170+
FileSpanExporter,
171+
)
172+
except ImportError as exc:
173+
raise ConfigurationError(
174+
"otlp_file_development span exporter requires 'opentelemetry-exporter-otlp-json-file'. "
175+
"Install it with: pip install opentelemetry-exporter-otlp-json-file"
176+
) from exc
177+
178+
path = _parse_otlp_file_output_stream(config.output_stream)
179+
return FileSpanExporter(path) if path is not None else FileSpanExporter()
180+
181+
159182
_SPAN_EXPORTER_REGISTRY: dict = {
160183
"otlp_http": _create_otlp_http_span_exporter,
161184
"otlp_grpc": _create_otlp_grpc_span_exporter,
162185
"console": lambda _: ConsoleSpanExporter(),
186+
"otlp_file_development": _create_otlp_file_development_span_exporter,
163187
}
164188

165189

@@ -171,11 +195,6 @@ def _create_span_exporter(config: SpanExporterConfig) -> SpanExporter:
171195
by the @_additional_properties decorator are loaded via the
172196
``opentelemetry_traces_exporter`` entry point group.
173197
"""
174-
if config.otlp_file_development is not None:
175-
raise ConfigurationError(
176-
"otlp_file_development span exporter is experimental "
177-
"and not yet supported."
178-
)
179198
for name, factory in _SPAN_EXPORTER_REGISTRY.items():
180199
value = getattr(config, name, None)
181200
if value is not None:
@@ -187,7 +206,7 @@ def _create_span_exporter(config: SpanExporterConfig) -> SpanExporter:
187206
)
188207
raise ConfigurationError(
189208
"No exporter type specified in span exporter config. "
190-
"Supported types: otlp_http, otlp_grpc, console."
209+
"Supported types: otlp_http, otlp_grpc, console, otlp_file_development."
191210
)
192211

193212

opentelemetry-sdk/tests/_configuration/test_common.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
_additional_properties,
1313
_map_compression,
1414
_parse_headers,
15+
_parse_otlp_file_output_stream,
1516
_resolve_component,
1617
load_entry_point,
1718
)
@@ -206,6 +207,93 @@ def test_http_error_message_includes_deflate(self):
206207
)
207208

208209

210+
class TestParseOtlpFileOutputStream(unittest.TestCase):
211+
def test_none_returns_none(self):
212+
self.assertIsNone(_parse_otlp_file_output_stream(None))
213+
214+
def test_stdout_returns_none(self):
215+
self.assertIsNone(_parse_otlp_file_output_stream("stdout"))
216+
217+
def test_file_uri_returns_path(self):
218+
self.assertEqual(
219+
_parse_otlp_file_output_stream("file:///tmp/traces.jsonl"),
220+
"/tmp/traces.jsonl",
221+
)
222+
223+
def test_file_uri_localhost_host_returns_path(self):
224+
self.assertEqual(
225+
_parse_otlp_file_output_stream(
226+
"file://localhost/tmp/traces.jsonl"
227+
),
228+
"/tmp/traces.jsonl",
229+
)
230+
231+
def test_file_uri_with_other_host_raises(self):
232+
with self.assertRaises(ConfigurationError) as ctx:
233+
_parse_otlp_file_output_stream("file://otherhost/tmp/traces.jsonl")
234+
235+
self.assertEqual(
236+
str(ctx.exception),
237+
"Unsupported output_stream 'file://otherhost/tmp/traces.jsonl' "
238+
"for otlp_file_development exporter. Supported values: stdout, "
239+
"file://<path>.",
240+
)
241+
242+
def test_file_uri_empty_path_raises(self):
243+
with self.assertRaises(ConfigurationError):
244+
_parse_otlp_file_output_stream("file://")
245+
246+
def test_file_uri_with_query_raises(self):
247+
with self.assertRaises(ConfigurationError):
248+
_parse_otlp_file_output_stream("file:///tmp/traces.jsonl?foo=bar")
249+
250+
def test_file_uri_with_fragment_raises(self):
251+
with self.assertRaises(ConfigurationError):
252+
_parse_otlp_file_output_stream("file:///tmp/traces.jsonl#frag")
253+
254+
def test_unsupported_scheme_raises(self):
255+
with self.assertRaises(ConfigurationError) as ctx:
256+
_parse_otlp_file_output_stream("http://example")
257+
258+
self.assertEqual(
259+
str(ctx.exception),
260+
"Unsupported output_stream 'http://example' for "
261+
"otlp_file_development exporter. Supported values: stdout, "
262+
"file://<path>.",
263+
)
264+
265+
def test_malformed_uri_raises_configuration_error(self):
266+
with self.assertRaises(ConfigurationError) as ctx:
267+
_parse_otlp_file_output_stream("file://[::1")
268+
269+
self.assertIn(
270+
"Failed to parse output_stream 'file://[::1' for "
271+
"otlp_file_development exporter",
272+
str(ctx.exception),
273+
)
274+
275+
def test_relative_path_raises(self):
276+
with self.assertRaises(ConfigurationError) as ctx:
277+
_parse_otlp_file_output_stream("file:traces.jsonl")
278+
279+
self.assertEqual(
280+
str(ctx.exception),
281+
"Unsupported output_stream 'file:traces.jsonl' for "
282+
"otlp_file_development exporter. Path must be absolute.",
283+
)
284+
285+
def test_trailing_slash_path_raises(self):
286+
with self.assertRaises(ConfigurationError) as ctx:
287+
_parse_otlp_file_output_stream("file:///tmp/output/")
288+
289+
self.assertEqual(
290+
str(ctx.exception),
291+
"Unsupported output_stream 'file:///tmp/output/' for "
292+
"otlp_file_development exporter. Path must be a file, "
293+
"not a directory.",
294+
)
295+
296+
209297
class TestAdditionalPropertiesSupport(unittest.TestCase):
210298
def setUp(self):
211299
@_additional_properties

0 commit comments

Comments
 (0)