diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5f0c4452..52362f6b61 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`: Add `create_tracer_provider`/`configure_tracer_provider` to declarative file configuration, enabling TracerProvider instantiation from config files without reading env vars + ([#4985](https://github.com/open-telemetry/opentelemetry-python/pull/4985)) - `opentelemetry-sdk`: Add shared `_parse_headers` helper for declarative config OTLP exporters ([#5021](https://github.com/open-telemetry/opentelemetry-python/pull/5021)) - `opentelemetry-api`: Replace a broad exception in attribute cleaning tests to satisfy pylint in the `lint-opentelemetry-api` CI job diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py index 60cc904f13..3c6372bb73 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py @@ -36,13 +36,16 @@ def _load_entry_point_propagator(name: str) -> TextMapPropagator: """Load a propagator by name from the opentelemetry_propagator entry point group.""" try: - eps = list(entry_points(group="opentelemetry_propagator", name=name)) - if not eps: + ep = next( + iter(entry_points(group="opentelemetry_propagator", name=name)), + None, + ) + if not ep: raise ConfigurationError( f"Propagator '{name}' not found. " "It may not be installed or may be misspelled." ) - return eps[0].load()() + return ep.load()() except ConfigurationError: raise except Exception as exc: @@ -85,19 +88,13 @@ def create_propagator( if config is None: return CompositePropagator([]) - propagators: list[TextMapPropagator] = [] - seen_types: set[type] = set() - - def _add_deduped(propagator: TextMapPropagator) -> None: - if type(propagator) not in seen_types: - seen_types.add(type(propagator)) - propagators.append(propagator) + propagators: dict[type[TextMapPropagator], TextMapPropagator] = {} # Process structured composite list if config.composite: for entry in config.composite: for propagator in _propagators_from_textmap_config(entry): - _add_deduped(propagator) + propagators.setdefault(type(propagator), propagator) # Process composite_list (comma-separated propagator names via entry_points) if config.composite_list: @@ -105,9 +102,10 @@ def _add_deduped(propagator: TextMapPropagator) -> None: name = name.strip() if not name or name.lower() == "none": continue - _add_deduped(_load_entry_point_propagator(name)) + propagator = _load_entry_point_propagator(name) + propagators.setdefault(type(propagator), propagator) - return CompositePropagator(propagators) + return CompositePropagator(list(propagators.values())) def configure_propagator(config: Optional[PropagatorConfig]) -> None: diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index d58bd4d31d..66e13a3145 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -170,13 +170,9 @@ def _filter_attributes( if not included and not excluded: return attrs - effective_included = included if included else None # [] → include all - result: dict[str, object] = {} for key, value in attrs.items(): - if effective_included is not None and not any( - fnmatch.fnmatch(key, pat) for pat in effective_included - ): + if included and not any(fnmatch.fnmatch(key, pat) for pat in included): continue if excluded and any(fnmatch.fnmatch(key, pat) for pat in excluded): continue diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py new file mode 100644 index 0000000000..32dfd96567 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -0,0 +1,327 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import logging +from typing import Optional + +from opentelemetry import trace +from opentelemetry.sdk._configuration._common import _parse_headers +from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + OtlpGrpcExporter as OtlpGrpcExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpHttpExporter as OtlpHttpExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + ParentBasedSampler as ParentBasedSamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + Sampler as SamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanExporter as SpanExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanLimits as SpanLimitsConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanProcessor as SpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + TracerProvider as TracerProviderConfig, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import ( + _DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT, + _DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT, + _DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, + _DEFAULT_OTEL_SPAN_EVENT_COUNT_LIMIT, + _DEFAULT_OTEL_SPAN_LINK_COUNT_LIMIT, + SpanLimits, + TracerProvider, +) +from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + ConsoleSpanExporter, + SimpleSpanProcessor, + SpanExporter, +) +from opentelemetry.sdk.trace.sampling import ( + ALWAYS_OFF, + ALWAYS_ON, + ParentBased, + Sampler, + TraceIdRatioBased, +) + +_logger = logging.getLogger(__name__) + +# Default sampler per the OTel spec: parent_based with always_on root. +_DEFAULT_SAMPLER = ParentBased(root=ALWAYS_ON) + + +def _create_otlp_http_span_exporter( + config: OtlpHttpExporterConfig, +) -> SpanExporter: + """Create an OTLP HTTP span exporter from config.""" + try: + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.exporter.otlp.proto.http import ( # type: ignore[import-untyped] # noqa: PLC0415 + Compression, + ) + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415 + OTLPSpanExporter, + ) + except ImportError as exc: + raise ConfigurationError( + "otlp_http span exporter requires 'opentelemetry-exporter-otlp-proto-http'. " + "Install it with: pip install opentelemetry-exporter-otlp-proto-http" + ) from exc + + 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 + + return OTLPSpanExporter( # type: ignore[return-value] + endpoint=config.endpoint, + headers=headers, + timeout=timeout, + 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: + """Create an OTLP gRPC span exporter from config.""" + try: + # pylint: disable=import-outside-toplevel,no-name-in-module + import grpc # type: ignore[import-untyped] # noqa: PLC0415 + + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415 + OTLPSpanExporter, + ) + except ImportError as exc: + raise ConfigurationError( + "otlp_grpc span exporter requires 'opentelemetry-exporter-otlp-proto-grpc'. " + "Install it with: pip install opentelemetry-exporter-otlp-proto-grpc" + ) from exc + + 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 + + return OTLPSpanExporter( # type: ignore[return-value] + endpoint=config.endpoint, + headers=headers, + timeout=timeout, + compression=compression, # type: ignore[arg-type] + ) + + +def _create_span_exporter(config: SpanExporterConfig) -> SpanExporter: + """Create a span exporter from config.""" + if config.otlp_http is not None: + return _create_otlp_http_span_exporter(config.otlp_http) + if config.otlp_grpc is not None: + return _create_otlp_grpc_span_exporter(config.otlp_grpc) + if config.console is not None: + return ConsoleSpanExporter() + raise ConfigurationError( + "No exporter type specified in span exporter config. " + "Supported types: otlp_http, otlp_grpc, console." + ) + + +def _create_span_processor( + config: SpanProcessorConfig, +) -> BatchSpanProcessor | SimpleSpanProcessor: + """Create a span processor from config.""" + if config.batch is not None: + exporter = _create_span_exporter(config.batch.exporter) + return BatchSpanProcessor( + exporter, + max_queue_size=config.batch.max_queue_size, + schedule_delay_millis=config.batch.schedule_delay, + max_export_batch_size=config.batch.max_export_batch_size, + export_timeout_millis=config.batch.export_timeout, + ) + if config.simple is not None: + return SimpleSpanProcessor( + _create_span_exporter(config.simple.exporter) + ) + raise ConfigurationError( + "No processor type specified in span processor config. " + "Supported types: batch, simple." + ) + + +def _create_sampler(config: SamplerConfig) -> Sampler: + """Create a sampler from config.""" + if config.always_on is not None: + return ALWAYS_ON + if config.always_off is not None: + return ALWAYS_OFF + if config.trace_id_ratio_based is not None: + ratio = config.trace_id_ratio_based.ratio + return TraceIdRatioBased(ratio if ratio is not None else 1.0) + if config.parent_based is not None: + return _create_parent_based_sampler(config.parent_based) + raise ConfigurationError( + f"Unknown or unsupported sampler type in config: {config!r}. " + "Supported types: always_on, always_off, trace_id_ratio_based, parent_based." + ) + + +def _create_parent_based_sampler(config: ParentBasedSamplerConfig) -> Sampler: + """Create a ParentBased sampler from config, applying SDK defaults for absent delegates.""" + root = ( + _create_sampler(config.root) if config.root is not None else ALWAYS_ON + ) + kwargs: dict = {"root": root} + if config.remote_parent_sampled is not None: + kwargs["remote_parent_sampled"] = _create_sampler( + config.remote_parent_sampled + ) + if config.remote_parent_not_sampled is not None: + kwargs["remote_parent_not_sampled"] = _create_sampler( + config.remote_parent_not_sampled + ) + if config.local_parent_sampled is not None: + kwargs["local_parent_sampled"] = _create_sampler( + config.local_parent_sampled + ) + if config.local_parent_not_sampled is not None: + kwargs["local_parent_not_sampled"] = _create_sampler( + config.local_parent_not_sampled + ) + return ParentBased(**kwargs) + + +def _create_span_limits(config: SpanLimitsConfig) -> SpanLimits: + """Create SpanLimits from config. + + Absent fields use the OTel spec defaults (128 for counts, unlimited for lengths). + Explicit values suppress env-var reading — matching Java SDK behavior. + """ + return SpanLimits( + max_span_attributes=( + config.attribute_count_limit + if config.attribute_count_limit is not None + else _DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT + ), + max_events=( + config.event_count_limit + if config.event_count_limit is not None + else _DEFAULT_OTEL_SPAN_EVENT_COUNT_LIMIT + ), + max_links=( + config.link_count_limit + if config.link_count_limit is not None + else _DEFAULT_OTEL_SPAN_LINK_COUNT_LIMIT + ), + max_event_attributes=( + config.event_attribute_count_limit + if config.event_attribute_count_limit is not None + else _DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT + ), + max_link_attributes=( + config.link_attribute_count_limit + if config.link_attribute_count_limit is not None + else _DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT + ), + max_attribute_length=config.attribute_value_length_limit, + ) + + +def create_tracer_provider( + config: Optional[TracerProviderConfig], + resource: Optional[Resource] = None, +) -> TracerProvider: + """Create an SDK TracerProvider from declarative config. + + Does NOT read OTEL_TRACES_SAMPLER, OTEL_SPAN_*_LIMIT, or any other env vars + for values that are explicitly controlled by the config. Absent config values + use OTel spec defaults (not env vars), matching Java SDK behavior. + + Args: + config: TracerProvider config from the parsed config file, or None. + resource: Resource to attach to the provider. + + Returns: + A configured TracerProvider. + """ + sampler = ( + _create_sampler(config.sampler) + if config is not None and config.sampler is not None + else _DEFAULT_SAMPLER + ) + span_limits = ( + _create_span_limits(config.limits) + if config is not None and config.limits is not None + else SpanLimits( + max_span_attributes=_DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, + max_events=_DEFAULT_OTEL_SPAN_EVENT_COUNT_LIMIT, + max_links=_DEFAULT_OTEL_SPAN_LINK_COUNT_LIMIT, + max_event_attributes=_DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT, + max_link_attributes=_DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT, + ) + ) + + provider = TracerProvider( + resource=resource, + sampler=sampler, + span_limits=span_limits, + ) + + if config is not None: + for proc_config in config.processors: + provider.add_span_processor(_create_span_processor(proc_config)) + + return provider + + +def configure_tracer_provider( + config: Optional[TracerProviderConfig], + resource: Optional[Resource] = None, +) -> None: + """Configure the global TracerProvider from declarative config. + + When config is None (tracer_provider section absent from config file), + the global is not set — matching Java/JS SDK behavior and the spec's + "a noop tracer provider is used" default. + + Args: + config: TracerProvider config from the parsed config file, or None. + resource: Resource to attach to the provider. + """ + if config is None: + return + trace.set_tracer_provider(create_tracer_provider(config, resource)) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py index 664bb8bc29..838ef6fd3e 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py @@ -30,6 +30,10 @@ create_propagator, ) from opentelemetry.sdk._configuration._resource import create_resource +from opentelemetry.sdk._configuration._tracer_provider import ( + configure_tracer_provider, + create_tracer_provider, +) from opentelemetry.sdk._configuration.file._env_substitution import ( EnvSubstitutionError, substitute_env_vars, @@ -44,4 +48,6 @@ "create_resource", "create_propagator", "configure_propagator", + "create_tracer_provider", + "configure_tracer_provider", ] diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py new file mode 100644 index 0000000000..5caf077cd5 --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -0,0 +1,414 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Tests access private members of SDK classes to assert correct configuration. +# pylint: disable=protected-access + +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +from opentelemetry.sdk._configuration._tracer_provider import ( + configure_tracer_provider, + create_tracer_provider, +) +from opentelemetry.sdk._configuration.file._loader import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + BatchSpanProcessor as BatchSpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpGrpcExporter as OtlpGrpcExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpHttpExporter as OtlpHttpExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + ParentBasedSampler as ParentBasedSamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + Sampler as SamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + SimpleSpanProcessor as SimpleSpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanExporter as SpanExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanLimits as SpanLimitsConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanProcessor as SpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + TraceIdRatioBasedSampler as TraceIdRatioBasedConfig, +) +from opentelemetry.sdk._configuration.models import ( + TracerProvider as TracerProviderConfig, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + ConsoleSpanExporter, + SimpleSpanProcessor, +) +from opentelemetry.sdk.trace.sampling import ( + ALWAYS_OFF, + ALWAYS_ON, + ParentBased, + TraceIdRatioBased, +) + + +class TestCreateTracerProviderBasic(unittest.TestCase): + def test_none_config_returns_provider(self): + resource = Resource({"service.name": "test"}) + provider = create_tracer_provider(None, resource) + self.assertIsInstance(provider, TracerProvider) + + def test_none_config_uses_supplied_resource(self): + resource = Resource({"service.name": "svc"}) + provider = create_tracer_provider(None, resource) + self.assertIs(provider._resource, resource) + + def test_none_config_uses_default_sampler(self): + provider = create_tracer_provider(None) + self.assertIsInstance(provider.sampler, ParentBased) + + def test_none_config_no_processors(self): + provider = create_tracer_provider(None) + self.assertEqual( + len(provider._active_span_processor._span_processors), 0 + ) + + def test_none_config_does_not_read_sampler_env_var(self): + with patch.dict(os.environ, {"OTEL_TRACES_SAMPLER": "always_off"}): + provider = create_tracer_provider(None) + self.assertIsInstance(provider.sampler, ParentBased) + + def test_none_config_does_not_read_span_limit_env_var(self): + with patch.dict(os.environ, {"OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT": "1"}): + provider = create_tracer_provider(None) + self.assertEqual(provider._span_limits.max_span_attributes, 128) + + def test_configure_none_does_not_set_global(self): + original = __import__( + "opentelemetry.trace", fromlist=["get_tracer_provider"] + ).get_tracer_provider() + configure_tracer_provider(None) + after = __import__( + "opentelemetry.trace", fromlist=["get_tracer_provider"] + ).get_tracer_provider() + self.assertIs(original, after) + + def test_configure_with_config_sets_global(self): + config = TracerProviderConfig(processors=[]) + with patch( + "opentelemetry.sdk._configuration._tracer_provider.trace.set_tracer_provider" + ) as mock_set: + configure_tracer_provider(config) + mock_set.assert_called_once() + arg = mock_set.call_args[0][0] + self.assertIsInstance(arg, TracerProvider) + + def test_processors_added_in_order(self): + mock_proc_a = MagicMock() + mock_proc_b = MagicMock() + config = TracerProviderConfig(processors=[]) + provider = create_tracer_provider(config) + provider.add_span_processor(mock_proc_a) + provider.add_span_processor(mock_proc_b) + procs = provider._active_span_processor._span_processors + self.assertIs(procs[0], mock_proc_a) + self.assertIs(procs[1], mock_proc_b) + + def test_span_limits_from_config(self): + config = TracerProviderConfig( + processors=[], + limits=SpanLimitsConfig( + attribute_count_limit=5, + event_count_limit=10, + link_count_limit=3, + ), + ) + provider = create_tracer_provider(config) + self.assertEqual(provider._span_limits.max_span_attributes, 5) + self.assertEqual(provider._span_limits.max_events, 10) + self.assertEqual(provider._span_limits.max_links, 3) + + +class TestCreateSampler(unittest.TestCase): + @staticmethod + def _make_provider(sampler_config): + return create_tracer_provider( + TracerProviderConfig(processors=[], sampler=sampler_config) + ) + + def test_always_on(self): + provider = self._make_provider(SamplerConfig(always_on={})) + self.assertIs(provider.sampler, ALWAYS_ON) + + def test_always_off(self): + provider = self._make_provider(SamplerConfig(always_off={})) + self.assertIs(provider.sampler, ALWAYS_OFF) + + def test_trace_id_ratio_based(self): + provider = self._make_provider( + SamplerConfig( + trace_id_ratio_based=TraceIdRatioBasedConfig(ratio=0.5) + ) + ) + self.assertIsInstance(provider.sampler, TraceIdRatioBased) + self.assertAlmostEqual(provider.sampler._rate, 0.5) + + def test_trace_id_ratio_based_none_ratio_defaults_to_1(self): + provider = self._make_provider( + SamplerConfig(trace_id_ratio_based=TraceIdRatioBasedConfig()) + ) + self.assertIsInstance(provider.sampler, TraceIdRatioBased) + self.assertAlmostEqual(provider.sampler._rate, 1.0) + + def test_parent_based_with_root(self): + provider = self._make_provider( + SamplerConfig( + parent_based=ParentBasedSamplerConfig( + root=SamplerConfig(always_on={}) + ) + ) + ) + self.assertIsInstance(provider.sampler, ParentBased) + + def test_parent_based_no_root_defaults_to_always_on(self): + provider = self._make_provider( + SamplerConfig(parent_based=ParentBasedSamplerConfig()) + ) + self.assertIsInstance(provider.sampler, ParentBased) + self.assertIs(provider.sampler._root, ALWAYS_ON) + + def test_parent_based_with_delegate_samplers(self): + provider = self._make_provider( + SamplerConfig( + parent_based=ParentBasedSamplerConfig( + root=SamplerConfig(always_on={}), + remote_parent_sampled=SamplerConfig(always_on={}), + remote_parent_not_sampled=SamplerConfig(always_off={}), + local_parent_sampled=SamplerConfig(always_on={}), + local_parent_not_sampled=SamplerConfig(always_off={}), + ) + ) + ) + sampler = provider.sampler + self.assertIsInstance(sampler, ParentBased) + self.assertIs(sampler._remote_parent_sampled, ALWAYS_ON) + self.assertIs(sampler._remote_parent_not_sampled, ALWAYS_OFF) + self.assertIs(sampler._local_parent_sampled, ALWAYS_ON) + self.assertIs(sampler._local_parent_not_sampled, ALWAYS_OFF) + + def test_unknown_sampler_raises_configuration_error(self): + with self.assertRaises(ConfigurationError): + create_tracer_provider( + TracerProviderConfig(processors=[], sampler=SamplerConfig()) + ) + + +class TestCreateSpanExporterAndProcessor(unittest.TestCase): + # pylint: disable=no-self-use + + @staticmethod + def _make_batch_config(exporter_config): + return TracerProviderConfig( + processors=[ + SpanProcessorConfig( + batch=BatchSpanProcessorConfig(exporter=exporter_config) + ) + ] + ) + + @staticmethod + def _make_simple_config(exporter_config): + return TracerProviderConfig( + processors=[ + SpanProcessorConfig( + simple=SimpleSpanProcessorConfig(exporter=exporter_config) + ) + ] + ) + + def test_console_exporter_batch(self): + config = self._make_batch_config(SpanExporterConfig(console={})) + provider = create_tracer_provider(config) + procs = provider._active_span_processor._span_processors + self.assertEqual(len(procs), 1) + self.assertIsInstance(procs[0], BatchSpanProcessor) + self.assertIsInstance(procs[0].span_exporter, ConsoleSpanExporter) + + def test_console_exporter_simple(self): + config = self._make_simple_config(SpanExporterConfig(console={})) + provider = create_tracer_provider(config) + procs = provider._active_span_processor._span_processors + self.assertIsInstance(procs[0], SimpleSpanProcessor) + self.assertIsInstance(procs[0].span_exporter, ConsoleSpanExporter) + + def test_otlp_http_missing_package_raises(self): + config = self._make_batch_config( + SpanExporterConfig(otlp_http=OtlpHttpExporterConfig()) + ) + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http.trace_exporter": None, + "opentelemetry.exporter.otlp.proto.http": None, + }, + ): + with self.assertRaises(ConfigurationError) as ctx: + create_tracer_provider(config) + self.assertIn("otlp-proto-http", str(ctx.exception)) + + def test_otlp_http_created_with_endpoint(self): + mock_exporter_cls = MagicMock() + mock_compression_cls = MagicMock() + mock_compression_cls.Gzip = "gzip_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( + endpoint="http://localhost:4318" + ) + ) + ) + create_tracer_provider(config) + + mock_exporter_cls.assert_called_once_with( + endpoint="http://localhost:4318", + headers=None, + timeout=None, + compression=None, + ) + + def test_otlp_http_headers_list(self): + mock_exporter_cls = MagicMock() + mock_http_module = MagicMock() + mock_module = MagicMock() + mock_module.OTLPSpanExporter = mock_exporter_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( + headers_list="x-api-key=secret,env=prod" + ) + ) + ) + create_tracer_provider(config) + + _, kwargs = mock_exporter_cls.call_args + self.assertEqual( + kwargs["headers"], {"x-api-key": "secret", "env": "prod"} + ) + + def test_otlp_grpc_missing_package_raises(self): + config = self._make_batch_config( + SpanExporterConfig(otlp_grpc=OtlpGrpcExporterConfig()) + ) + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.grpc.trace_exporter": None, + "grpc": None, + }, + ): + with self.assertRaises(ConfigurationError) as ctx: + create_tracer_provider(config) + self.assertIn("otlp-proto-grpc", str(ctx.exception)) + + def test_no_processor_type_raises(self): + config = TracerProviderConfig(processors=[SpanProcessorConfig()]) + with self.assertRaises(ConfigurationError): + create_tracer_provider(config) + + def test_no_exporter_type_raises(self): + config = self._make_batch_config(SpanExporterConfig()) + with self.assertRaises(ConfigurationError): + create_tracer_provider(config) + + +class TestCreateSpanLimits(unittest.TestCase): + # pylint: disable=no-self-use + + @staticmethod + def _create_with_limits(limits_config): + return create_tracer_provider( + TracerProviderConfig(processors=[], limits=limits_config) + ) + + def test_explicit_attribute_count_limit(self): + provider = self._create_with_limits( + SpanLimitsConfig(attribute_count_limit=10) + ) + self.assertEqual(provider._span_limits.max_span_attributes, 10) + + def test_explicit_event_count_limit(self): + provider = self._create_with_limits( + SpanLimitsConfig(event_count_limit=5) + ) + self.assertEqual(provider._span_limits.max_events, 5) + + def test_explicit_link_count_limit(self): + provider = self._create_with_limits( + SpanLimitsConfig(link_count_limit=2) + ) + self.assertEqual(provider._span_limits.max_links, 2) + + def test_explicit_attribute_value_length_limit(self): + provider = self._create_with_limits( + SpanLimitsConfig(attribute_value_length_limit=64) + ) + self.assertEqual(provider._span_limits.max_attribute_length, 64) + + def test_absent_limits_use_spec_defaults(self): + provider = self._create_with_limits(SpanLimitsConfig()) + self.assertEqual(provider._span_limits.max_span_attributes, 128) + self.assertEqual(provider._span_limits.max_events, 128) + self.assertEqual(provider._span_limits.max_links, 128) + + def test_absent_limits_do_not_read_env_vars(self): + with patch.dict( + os.environ, + { + "OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT": "1", + "OTEL_SPAN_EVENT_COUNT_LIMIT": "2", + }, + ): + provider = self._create_with_limits(SpanLimitsConfig()) + self.assertEqual(provider._span_limits.max_span_attributes, 128) + self.assertEqual(provider._span_limits.max_events, 128)