Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/5311.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-sdk`: add support for file exporter with declarative config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -194,3 +195,32 @@ 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
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 "
"exporter. Supported values: stdout, file://<path>."
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
}


Expand All @@ -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:
Expand All @@ -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."
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
}


Expand All @@ -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:
Expand All @@ -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."
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
}


Expand All @@ -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:
Expand All @@ -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."
)


Expand Down
67 changes: 67 additions & 0 deletions opentelemetry-sdk/tests/_configuration/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
_additional_properties,
_map_compression,
_parse_headers,
_parse_otlp_file_output_stream,
_resolve_component,
load_entry_point,
)
Expand Down Expand Up @@ -206,6 +207,72 @@ 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_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://<path>.",
)

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")

self.assertEqual(
str(ctx.exception),
"Unsupported output_stream 'http://example' for "
"otlp_file_development exporter. Supported values: stdout, "
"file://<path>.",
)

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
Expand Down
Loading
Loading