Skip to content

Commit ff58266

Browse files
authored
Merge branch 'main' into extended_attributes
2 parents eb12236 + ed622c3 commit ff58266

9 files changed

Lines changed: 158 additions & 12 deletions

File tree

.changelog/5214.changed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-sdk`: rename "known/unknown" to "built-in/user-defined" terminology in declarative config component loading code

.changelog/5215.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-sdk`: add `_resolve_component` shared utility for declarative config plugin loading, reducing boilerplate in exporter factory functions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ For more information about the maintainer role, see the [community repository](h
115115
- [Dylan Russell](https://github.com/dylanrussell), Google
116116
- [Emídio Neto](https://github.com/emdneto), Independent
117117
- [Héctor Hernández](https://github.com/hectorhdzg), Microsoft
118-
- [Liudmila Molkova](https://github.com/lmolkova), Grafana Labs
118+
- [Liudmila Molkova](https://github.com/lmolkova), Google
119119
- [Lukas Hering](https://github.com/herin049), Oracle
120120
- [Mike Goldsmith](https://github.com/MikeGoldsmith), Honeycomb
121121
- [Pablo Collins](https://github.com/pmcollins), Splunk

opentelemetry-sdk/codegen/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Custom [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-gen
66

77
Extends the default dataclass template to support `additionalProperties` from the JSON Schema. Schema types that allow additional properties (e.g. `Sampler`, `SpanExporter`, `TextMapPropagator`) get:
88

9-
- `@_additional_properties` decorator — captures unknown constructor kwargs
9+
- `@_additional_properties` decorator — captures user-defined constructor kwargs
1010
- `additional_properties: ClassVar[dict[str, Any]]` annotation — satisfies type checkers without creating a dataclass field
1111

1212
This enables plugin/custom component names to flow through typed dataclasses without a post-processing step.

opentelemetry-sdk/codegen/dataclass.jinja2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Extends the default datamodel-codegen dataclass template to support
33
JSON Schema additionalProperties. When a schema type allows additional
44
properties (e.g. Sampler, SpanExporter), this template adds:
5-
- @_additional_properties decorator (captures unknown kwargs)
5+
- @_additional_properties decorator (captures user-defined kwargs)
66
- additional_properties: ClassVar[dict[str, Any]] annotation (for type checkers)
77
88
The template checks two context variables set by datamodel-codegen:

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

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import dataclasses
77
import inspect
88
import logging
9+
from collections.abc import Callable
10+
from typing import Any, Protocol
911

1012
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
1113
from opentelemetry.util._importlib_metadata import entry_points
@@ -16,11 +18,11 @@
1618
def _additional_properties(cls):
1719
"""Decorator for dataclasses whose JSON Schema sets additionalProperties.
1820
19-
Wraps the dataclass-generated ``__init__`` so that unknown keyword
21+
Wraps the dataclass-generated ``__init__`` so that extra keyword
2022
arguments are captured into an ``additional_properties`` instance
21-
attribute instead of raising ``TypeError``. This lets plugin/custom
23+
attribute instead of raising ``TypeError``. This lets user-defined
2224
component names flow through the config pipeline without modifying
23-
the codegen output for known fields.
25+
the codegen output for built-in fields.
2426
2527
Applied automatically by the custom template in ``opentelemetry-sdk/codegen/``
2628
when ``additionalPropertiesType`` is present in the template context
@@ -72,6 +74,66 @@ def load_entry_point(group: str, name: str) -> type:
7274
) from exc
7375

7476

77+
class _ComponentConfig(Protocol):
78+
"""Protocol for config dataclasses decorated with @_additional_properties.
79+
80+
Values in ``additional_properties`` are nested config dicts (suitable
81+
for ``**kwargs`` splatting to the user-defined component class) or
82+
``None`` (when the YAML uses ``my_plugin:`` or ``my_plugin: null``).
83+
84+
Note: the generated models declare ``additional_properties`` as a
85+
``ClassVar`` even though the decorator assigns it as an instance
86+
attribute at runtime. This is tolerated by pyright in ``standard``
87+
mode but flagged in ``strict`` mode. See #5268.
88+
"""
89+
90+
additional_properties: dict[str, dict[str, Any] | None]
91+
92+
93+
def _resolve_component(
94+
config: _ComponentConfig,
95+
registry: dict[str, Callable[[Any], Any]],
96+
entry_point_group: str,
97+
component_type: str,
98+
) -> Any:
99+
"""Resolve a config dataclass to a component instance.
100+
101+
Checks built-in factories in ``registry`` first (by matching typed
102+
field names on ``config``), then falls back to entry point loading
103+
for plugin components found in ``config.additional_properties``.
104+
105+
The JSON schema enforces exactly one component per config block
106+
(``minProperties: 1, maxProperties: 1``). If multiple typed fields or
107+
``additional_properties`` entries are set (e.g. when schema validation
108+
is bypassed), the first registry match wins.
109+
110+
Args:
111+
config: A dataclass with ``additional_properties`` (from the
112+
``@_additional_properties`` decorator).
113+
registry: Mapping of built-in component names to factory
114+
callables. Each factory receives the field value from config.
115+
entry_point_group: The entry point group name for plugin loading.
116+
component_type: Human-readable name for error messages
117+
(e.g. "span exporter").
118+
119+
Returns:
120+
The resolved component instance.
121+
122+
Raises:
123+
ConfigurationError: If no component type is specified in config.
124+
"""
125+
for name, factory in registry.items():
126+
value = getattr(config, name, None)
127+
if value is not None:
128+
return factory(value)
129+
if config.additional_properties:
130+
name, plugin_config = next(iter(config.additional_properties.items()))
131+
return load_entry_point(entry_point_group, name)(
132+
**(plugin_config or {})
133+
)
134+
raise ConfigurationError(f"No {component_type} type specified in config.")
135+
136+
75137
def _parse_headers(
76138
headers: list | None,
77139
headers_list: str | None,

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,8 @@ def _create_span_processor(
188188
def _create_sampler(config: SamplerConfig) -> Sampler:
189189
"""Create a sampler from config.
190190
191-
Known sampler types are checked via typed fields on the Sampler
192-
dataclass. Unknown sampler names captured in additional_properties
191+
Built-in sampler types are checked via typed fields on the Sampler
192+
dataclass. User-defined sampler names captured in additional_properties
193193
by the @_additional_properties decorator are loaded via the
194194
``opentelemetry_sampler`` entry point group.
195195
"""
@@ -206,7 +206,7 @@ def _create_sampler(config: SamplerConfig) -> Sampler:
206206
name = next(iter(config.additional_properties))
207207
return load_entry_point("opentelemetry_sampler", name)()
208208
raise ConfigurationError(
209-
f"Unknown or unsupported sampler type in config: {config!r}. "
209+
f"Unsupported sampler type in config: {config!r}. "
210210
"Supported types: always_on, always_off, trace_id_ratio_based, parent_based."
211211
)
212212

opentelemetry-sdk/tests/_configuration/test_common.py

Lines changed: 82 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+
_resolve_component,
1516
load_entry_point,
1617
)
1718
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
@@ -289,3 +290,84 @@ def test_log_record_exporter(self):
289290

290291
def test_push_metric_exporter(self):
291292
self._assert_supports_additional_properties(PushMetricExporter)
293+
294+
295+
class TestResolveComponent(unittest.TestCase):
296+
def setUp(self):
297+
@_additional_properties
298+
@dataclass
299+
class _Config:
300+
builtin_a: dict | None = None
301+
builtin_b: str | None = None
302+
additional_properties: ClassVar[dict[str, Any]]
303+
304+
self.cls = _Config
305+
self.registry = {
306+
"builtin_a": lambda v: ("resolved_a", v),
307+
"builtin_b": lambda v: ("resolved_b", v),
308+
}
309+
310+
def test_resolves_builtin_from_registry(self):
311+
config = self.cls(builtin_a={"key": "val"})
312+
result = _resolve_component(
313+
config, self.registry, "test_group", "test component"
314+
)
315+
self.assertEqual(result, ("resolved_a", {"key": "val"}))
316+
317+
def test_resolves_plugin_via_entry_point(self):
318+
mock_instance = MagicMock()
319+
mock_class = MagicMock(return_value=mock_instance)
320+
with patch(
321+
"opentelemetry.sdk._configuration._common.entry_points",
322+
return_value=[MagicMock(**{"load.return_value": mock_class})],
323+
):
324+
# pylint: disable=unexpected-keyword-arg
325+
config = self.cls(my_plugin={"opt": "val"})
326+
result = _resolve_component(
327+
config, self.registry, "test_group", "test component"
328+
)
329+
self.assertIs(result, mock_instance)
330+
mock_class.assert_called_once_with(opt="val")
331+
332+
def test_plugin_with_empty_config(self):
333+
mock_instance = MagicMock()
334+
mock_class = MagicMock(return_value=mock_instance)
335+
with patch(
336+
"opentelemetry.sdk._configuration._common.entry_points",
337+
return_value=[MagicMock(**{"load.return_value": mock_class})],
338+
):
339+
# pylint: disable=unexpected-keyword-arg
340+
config = self.cls(my_plugin={})
341+
_resolve_component(
342+
config, self.registry, "test_group", "test component"
343+
)
344+
mock_class.assert_called_once_with()
345+
346+
def test_no_component_raises_configuration_error(self):
347+
config = self.cls()
348+
with self.assertRaises(ConfigurationError):
349+
_resolve_component(
350+
config, self.registry, "test_group", "test component"
351+
)
352+
353+
def test_plugin_not_found_raises_configuration_error(self):
354+
with patch(
355+
"opentelemetry.sdk._configuration._common.entry_points",
356+
return_value=[],
357+
):
358+
# pylint: disable=unexpected-keyword-arg
359+
config = self.cls(missing_plugin={})
360+
with self.assertRaises(ConfigurationError):
361+
_resolve_component(
362+
config, self.registry, "test_group", "test component"
363+
)
364+
365+
def test_first_registry_match_wins_when_multiple_set(self):
366+
"""When multiple built-in fields are set (which the schema should
367+
prevent), the first registry match wins."""
368+
config = self.cls(builtin_a={"a": 1}, builtin_b="b")
369+
result = _resolve_component(
370+
config, self.registry, "test_group", "test component"
371+
)
372+
# builtin_a comes first in the registry dict
373+
self.assertEqual(result, ("resolved_a", {"a": 1}))

opentelemetry-sdk/tests/_configuration/test_tracer_provider.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,11 @@ def test_parent_based_with_delegate_samplers(self):
207207
self.assertIs(sampler._local_parent_sampled, ALWAYS_ON)
208208
self.assertIs(sampler._local_parent_not_sampled, ALWAYS_OFF)
209209

210-
def test_unknown_sampler_raises_configuration_error(self):
210+
def test_no_sampler_raises_configuration_error(self):
211211
with self.assertRaises(ConfigurationError):
212212
self._make_provider(SamplerConfig())
213213

214-
def test_plugin_sampler_loaded_via_entry_point(self):
214+
def test_user_defined_sampler_loaded_via_entry_point(self):
215215
mock_sampler = MagicMock(spec=Sampler)
216216
mock_class = MagicMock(return_value=mock_sampler)
217217
with patch(
@@ -222,7 +222,7 @@ def test_plugin_sampler_loaded_via_entry_point(self):
222222
provider = self._make_provider(SamplerConfig(my_custom_sampler={}))
223223
self.assertIs(provider.sampler, mock_sampler)
224224

225-
def test_unknown_plugin_raises_configuration_error(self):
225+
def test_user_defined_sampler_not_found_raises_configuration_error(self):
226226
with patch(
227227
"opentelemetry.sdk._configuration._common.entry_points",
228228
return_value=[],

0 commit comments

Comments
 (0)