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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- `opentelemetry-sdk`: add sampler plugin loading to declarative file configuration via the `opentelemetry_sampler` entry point group, matching the spec's PluginComponentProvider mechanism
([#5071](https://github.com/open-telemetry/opentelemetry-python/pull/5071))
- `opentelemetry-sdk`: add `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it
([#5093](https://github.com/open-telemetry/opentelemetry-python/pull/5093))
- `opentelemetry-sdk`: Add `create_logger_provider`/`configure_logger_provider` to declarative file configuration, enabling LoggerProvider instantiation from config files without reading env vars
([#4990](https://github.com/open-telemetry/opentelemetry-python/pull/4990))
- `opentelemetry-sdk`: Add `service` resource detector support to declarative file configuration via `detection_development.detectors[].service`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,39 @@
from __future__ import annotations

import logging
from typing import Optional
from typing import Optional, Type

from opentelemetry.sdk._configuration._exceptions import ConfigurationError
from opentelemetry.util._importlib_metadata import entry_points

_logger = logging.getLogger(__name__)


def load_entry_point(group: str, name: str) -> Type:
"""Load a plugin class from an entry point group by name.

Returns the loaded class — callers are responsible for instantiation
with whatever arguments their config requires.

Raises:
ConfigurationError: If the entry point is not found or fails to load.
"""
try:
ep = next(iter(entry_points(group=group, name=name)), None)
if ep is None:
raise ConfigurationError(
f"Plugin '{name}' not found in group '{group}'. "
"Make sure the package providing this plugin is installed."
)
return ep.load()
except ConfigurationError:
raise
except Exception as exc:
raise ConfigurationError(
f"Failed to load plugin '{name}' from group '{group}': {exc}"
) from exc


def _parse_headers(
headers: Optional[list],
headers_list: Optional[str],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.propagators.textmap import TextMapPropagator
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
from opentelemetry.sdk._configuration._common import load_entry_point
from opentelemetry.sdk._configuration.models import (
Propagator as PropagatorConfig,
)
Expand All @@ -30,28 +30,11 @@
from opentelemetry.trace.propagation.tracecontext import (
TraceContextTextMapPropagator,
)
from opentelemetry.util._importlib_metadata import entry_points


def _load_entry_point_propagator(name: str) -> TextMapPropagator:
"""Load a propagator by name from the opentelemetry_propagator entry point group."""
try:
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 ep.load()()
except ConfigurationError:
raise
except Exception as exc:
raise ConfigurationError(
f"Failed to load propagator '{name}': {exc}"
) from exc
"""Load and instantiate a propagator by name."""
return load_entry_point("opentelemetry_propagator", name)()


def _propagators_from_textmap_config(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,20 @@
from __future__ import annotations

import logging
from typing import Optional
from typing import Any, Optional

from opentelemetry import trace
from opentelemetry.sdk._configuration._common import _parse_headers
from opentelemetry.sdk._configuration._common import (
_parse_headers,
load_entry_point,
)
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,
)
Expand Down Expand Up @@ -183,45 +180,46 @@ def _create_span_processor(
)


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."
)
_SAMPLER_REGISTRY: dict[str, Any] = {
"always_on": lambda _: ALWAYS_ON,
"always_off": lambda _: ALWAYS_OFF,
"trace_id_ratio_based": lambda c: TraceIdRatioBased(
(c or {}).get("ratio", 1.0)
),
"parent_based": lambda c: _create_parent_based_sampler(c or {}),
}


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
def _create_sampler(config: dict) -> Sampler:
"""Create a sampler from a config dict with a single key naming the sampler type.

Known names (always_on, always_off, trace_id_ratio_based, parent_based) are
bootstrapped directly. Unknown names are looked up via the
``opentelemetry_sampler`` entry point group, matching the spec's
PluginComponentProvider mechanism.
"""
if len(config) != 1:
raise ConfigurationError(
f"Sampler config must have exactly one key, got: {list(config.keys())}"
)
name, sampler_config = next(iter(config.items()))
if name in _SAMPLER_REGISTRY:
return _SAMPLER_REGISTRY[name](sampler_config)
return load_entry_point("opentelemetry_sampler", name)()


def _create_parent_based_sampler(config: dict) -> Sampler:
"""Create a ParentBased sampler from a config dict, applying SDK defaults for absent delegates."""
root = _create_sampler(config["root"]) if "root" in config else ALWAYS_ON
kwargs: dict = {"root": root}
for key in (
"remote_parent_sampled",
"remote_parent_not_sampled",
"local_parent_sampled",
"local_parent_not_sampled",
):
if key in config:
kwargs[key] = _create_sampler(config[key])
return ParentBased(**kwargs)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -759,24 +759,13 @@ class ExperimentalJaegerRemoteSampler:
interval: int | None = None


@dataclass
class ParentBasedSampler:
root: Sampler | None = None
remote_parent_sampled: Sampler | None = None
remote_parent_not_sampled: Sampler | None = None
local_parent_sampled: Sampler | None = None
local_parent_not_sampled: Sampler | None = None


@dataclass
class Sampler:
always_off: AlwaysOffSampler | None = None
always_on: AlwaysOnSampler | None = None
composite_development: ExperimentalComposableSampler | None = None
jaeger_remote_development: ExperimentalJaegerRemoteSampler | None = None
parent_based: ParentBasedSampler | None = None
probability_development: ExperimentalProbabilitySampler | None = None
trace_id_ratio_based: TraceIdRatioBasedSampler | None = None
# Diverges from codegen: Sampler and ParentBasedSampler are typed as
# dict[str, Any] rather than dataclasses so that unknown sampler names
# (plugin/custom samplers) are preserved as dict keys through the config
# pipeline. The loader stores nested fields as raw dicts anyway, so the
# typed dataclass representation would drop unknown keys silently.
Sampler: TypeAlias = dict[str, Any]
ParentBasedSampler: TypeAlias = dict[str, Any]


@dataclass
Expand Down
41 changes: 40 additions & 1 deletion opentelemetry-sdk/tests/_configuration/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@

import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

from opentelemetry.sdk._configuration._common import _parse_headers
from opentelemetry.sdk._configuration._common import (
_parse_headers,
load_entry_point,
)
from opentelemetry.sdk._configuration._exceptions import ConfigurationError


class TestParseHeaders(unittest.TestCase):
Expand Down Expand Up @@ -79,3 +84,37 @@ 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 TestLoadEntryPoint(unittest.TestCase):
def test_returns_loaded_class(self):
mock_class = MagicMock()
mock_ep = MagicMock()
mock_ep.load.return_value = mock_class
with patch(
"opentelemetry.sdk._configuration._common.entry_points",
return_value=[mock_ep],
):
result = load_entry_point("some_group", "some_name")
self.assertIs(result, mock_class)

def test_raises_when_not_found(self):
with patch(
"opentelemetry.sdk._configuration._common.entry_points",
return_value=[],
):
with self.assertRaises(ConfigurationError) as ctx:
load_entry_point("some_group", "missing")
self.assertIn("missing", str(ctx.exception))
self.assertIn("some_group", str(ctx.exception))

def test_wraps_load_exception_in_configuration_error(self):
mock_ep = MagicMock()
mock_ep.load.side_effect = ImportError("bad import")
with patch(
"opentelemetry.sdk._configuration._common.entry_points",
return_value=[mock_ep],
):
with self.assertRaises(ConfigurationError) as ctx:
load_entry_point("some_group", "some_name")
self.assertIn("bad import", str(ctx.exception))
18 changes: 9 additions & 9 deletions opentelemetry-sdk/tests/_configuration/test_propagator.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def test_b3_via_entry_point(self):
mock_ep.load.return_value = lambda: mock_propagator

with patch(
"opentelemetry.sdk._configuration._propagator.entry_points",
"opentelemetry.sdk._configuration._common.entry_points",
return_value=[mock_ep],
):
config = PropagatorConfig(
Expand All @@ -106,7 +106,7 @@ def test_b3multi_via_entry_point(self):
mock_ep.load.return_value = lambda: mock_propagator

with patch(
"opentelemetry.sdk._configuration._propagator.entry_points",
"opentelemetry.sdk._configuration._common.entry_points",
return_value=[mock_ep],
):
config = PropagatorConfig(
Expand All @@ -118,7 +118,7 @@ def test_b3multi_via_entry_point(self):

def test_b3_not_installed_raises_configuration_error(self):
with patch(
"opentelemetry.sdk._configuration._propagator.entry_points",
"opentelemetry.sdk._configuration._common.entry_points",
return_value=[],
):
config = PropagatorConfig(
Expand All @@ -135,7 +135,7 @@ def test_composite_list_tracecontext(self):
mock_ep.load.return_value = lambda: mock_tc

with patch(
"opentelemetry.sdk._configuration._propagator.entry_points",
"opentelemetry.sdk._configuration._common.entry_points",
return_value=[mock_ep],
):
result = create_propagator(config)
Expand All @@ -158,7 +158,7 @@ def fake_entry_points(group, name):
return []

with patch(
"opentelemetry.sdk._configuration._propagator.entry_points",
"opentelemetry.sdk._configuration._common.entry_points",
side_effect=fake_entry_points,
):
config = PropagatorConfig(composite_list="tracecontext,baggage")
Expand All @@ -182,7 +182,7 @@ def test_composite_list_whitespace_around_names(self):
mock_ep.load.return_value = lambda: mock_tc

with patch(
"opentelemetry.sdk._configuration._propagator.entry_points",
"opentelemetry.sdk._configuration._common.entry_points",
return_value=[mock_ep],
):
config = PropagatorConfig(composite_list=" tracecontext ")
Expand All @@ -195,7 +195,7 @@ def test_entry_point_load_exception_raises_configuration_error(self):
mock_ep.load.side_effect = RuntimeError("package broken")

with patch(
"opentelemetry.sdk._configuration._propagator.entry_points",
"opentelemetry.sdk._configuration._common.entry_points",
return_value=[mock_ep],
):
config = PropagatorConfig(composite_list="broken-prop")
Expand All @@ -210,7 +210,7 @@ def test_deduplication_across_composite_and_composite_list(self):
mock_ep.load.return_value = lambda: mock_tc

with patch(
"opentelemetry.sdk._configuration._propagator.entry_points",
"opentelemetry.sdk._configuration._common.entry_points",
return_value=[mock_ep],
):
config = PropagatorConfig(
Expand All @@ -229,7 +229,7 @@ def test_deduplication_across_composite_and_composite_list(self):

def test_unknown_composite_list_propagator_raises(self):
with patch(
"opentelemetry.sdk._configuration._propagator.entry_points",
"opentelemetry.sdk._configuration._common.entry_points",
return_value=[],
):
config = PropagatorConfig(composite_list="nonexistent")
Expand Down
Loading
Loading