Skip to content

Commit ac7a3df

Browse files
feat(config): support OTEL_CONFIG_FILE in the SDK configurator (open-telemetry#5271)
* recursively convert parsed dicts to typed dataclasses in loader Adds `_dict_to_dataclass` in `_conversion.py` which walks each field's type annotation and converts: - nested dicts → typed dataclass instances - lists of dicts → lists of typed dataclasses - string/value → Enum members (e.g. log_level: info) - unknown keys → routed to the @_additional_properties decorator The loader's `_dict_to_model` now produces a fully-typed OpenTelemetryConfiguration tree end-to-end. Factory functions can rely on typed attribute access (config.tracer_provider.processors[0].batch .exporter.otlp_http.endpoint) instead of failing on raw dicts. This closes the gap between load_config_file() and the factory functions — YAML/JSON config → SDK objects now works end-to-end. Closes open-telemetry#5127 Assisted-by: Claude Opus 4.6 * rename changelog fragment to PR open-telemetry#5269 * tighten typing on conversion module - Use TypeVar for _dict_to_dataclass return — callers now get the correct type instead of Any - Use collections.abc.Mapping for input (more permissive than dict) - Add explicit is_dataclass check at entry — raises TypeError with a descriptive message instead of failing later in dataclasses.fields Assisted-by: Claude Opus 4.6 * isolate typing.get_type_hints call to placate astroid 3.x on py3.14 Astroid 3.x (used by pylint 3.x) follows typing.get_type_hints into Python 3.14's annotationlib, which contains t-string literals it can't parse and crashes with AttributeError on 'visit_templatestr'. Wrapping the call in a helper that returns dict[str, Any] stops the inference at the declared return type. Assisted-by: Claude Opus 4.7 * inline the typing.get_type_hints wrap Same effect as the prior helper — declaring the local as ``dict[str, Any]`` stops astroid's inference at the annotation rather than tracing into the typing internals. Assisted-by: Claude Opus 4.7 * add configure_sdk orchestrator for declarative config Single entry point that takes a parsed OpenTelemetryConfiguration, builds the resource, and applies the tracer/meter/logger providers and propagator globally. Honors the top-level disabled flag — when true, no globals are touched. The orchestrator is a thin composition of the existing per-signal configure_* factories; the deeper unification with the env-var path (see open-telemetry#5126) is left for follow-up. Refs open-telemetry#3631 Refs open-telemetry#5126 Assisted-by: Claude Opus 4.7 * rename changelog fragment to PR open-telemetry#5270 Assisted-by: Claude Opus 4.7 * honor OTEL_CONFIG_FILE in the SDK configurator When the environment variable is set, route the SDK through the declarative config path — load the file via load_config_file() and apply it via configure_sdk() — in place of the env-var-based _initialize_components(). Other OTEL_* vars are ignored (per spec v1.0.0: when a config file is given, it is the sole source of truth). Kwargs passed to _OTelSDKConfigurator._configure are ignored with a warning when the file path is set, so distros that inject kwargs via super() see a clear signal rather than silent drops. The file-loader imports (pyyaml, jsonschema) stay lazy so installs without the file-configuration extras are not affected. Refs open-telemetry#3631 Assisted-by: Claude Opus 4.7 * rename changelog fragment to PR open-telemetry#5271 Assisted-by: Claude Opus 4.7 * use ExemplarFilter for enum coercion test fixture; allow 'astroid' in codespell Replace the bespoke _Level enum (which violated pylint's invalid-name on lowercase members) with the real ExemplarFilter enum from models.py — the generated models use lowercase values verbatim from the JSON schema, so using one of them avoids fighting the linter and exercises the same code path with real data shapes. Add 'astroid' to codespell's ignore-words-list; the prior commit's explanatory comment mentions the library by name and codespell flagged it as a misspelling of 'asteroid'. Assisted-by: Claude Opus 4.7 * fix lint on test_sdk.py: hoist import, disable no-self-use Move ``SdkTracerProvider`` import to module top (ruff PLC0415 / pylint C0415) and add explicit ``# pylint: disable=no-self-use`` on the three mock-only tests that intentionally do not touch ``self``. Assisted-by: Claude Opus 4.7 * silence pylint/ruff on intentional lazy imports The configure_sdk / load_config_file imports inside ``_configure`` are deliberately deferred so that the SDK does not pull in the optional file-configuration extras (pyyaml, jsonschema) unless ``OTEL_CONFIG_FILE`` is actually set. Annotate with the corresponding pylint and ruff suppressions; the existing comment already explains why. Assisted-by: Claude Opus 4.7 * remove extra blank line after imports (ruff I001) Assisted-by: Claude Opus 4.7 * collapse multi-line @patch decorators (ruff format) Assisted-by: Claude Opus 4.7 * add end-to-end loader tests covering YAML -> typed config -> factory The conversion module has unit tests that exercise _dict_to_dataclass in isolation, but nothing verified the full pipeline: load a real YAML file, get back fully-typed nested dataclasses, and feed the result into a downstream factory function. Adds two checks built on a representative nested fixture (tracer provider with a parent-based / trace-id-ratio sampler and a batch processor with console exporter): - nested fields (sampler, processors[*].batch) come back as the expected typed dataclasses, not raw dicts - the typed result is accepted by ``create_tracer_provider`` and produces an SDK ``TracerProvider`` This is the integration coverage requested in PR review feedback; the inline example in the PR description is now an actual regression test. Assisted-by: Claude Opus 4.7 * address review feedback on OTEL_CONFIG_FILE routing Use a walrus operator in _configure, simplify singleton reset to tearDown only, and hoist no-self-use pylint disable to file scope. * tighten OTEL_CONFIG_FILE docstring (review feedback from herin049) The previous wording overstated the env-var contract by implying all ``OTEL_*`` variables are ignored when ``OTEL_CONFIG_FILE`` is set. That's only true for spec-defined variables with schema equivalents: * resource detectors enabled in the config can still read env vars at runtime (e.g. ``OTEL_RESOURCE_ATTRIBUTES``, ``OTEL_SERVICE_NAME``) * ``${env:VAR}`` substitutions inside the file remain in effect Reword to be precise about both. Assisted-by: Claude Opus 4.7
1 parent fa75422 commit ac7a3df

4 files changed

Lines changed: 124 additions & 0 deletions

File tree

.changelog/5271.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-sdk`: the SDK configurator now honors the `OTEL_CONFIG_FILE` environment variable. When set, the SDK loads and applies the referenced declarative configuration file (YAML or JSON) in place of the env-var-based init path.

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
)
3838
from opentelemetry.sdk.environment_variables import (
3939
_OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED,
40+
OTEL_CONFIG_FILE,
4041
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL,
4142
OTEL_EXPORTER_OTLP_METRICS_PROTOCOL,
4243
OTEL_EXPORTER_OTLP_PROTOCOL,
@@ -720,4 +721,24 @@ class _OTelSDKConfigurator(_BaseConfigurator):
720721
"""
721722

722723
def _configure(self, **kwargs):
724+
if config_file := environ.get(OTEL_CONFIG_FILE):
725+
# Imported lazily so that the SDK does not require the optional
726+
# file-configuration extras (pyyaml, jsonschema) unless a config
727+
# file is actually requested.
728+
# pylint: disable=import-outside-toplevel
729+
from opentelemetry.sdk._configuration._sdk import ( # noqa: PLC0415
730+
configure_sdk,
731+
)
732+
from opentelemetry.sdk._configuration.file._loader import ( # noqa: PLC0415
733+
load_config_file,
734+
)
735+
736+
if kwargs:
737+
_logger.warning(
738+
"%s is set; ignoring configurator kwargs: %s",
739+
OTEL_CONFIG_FILE,
740+
sorted(kwargs),
741+
)
742+
configure_sdk(load_config_file(config_file))
743+
return
723744
_initialize_components(**kwargs)

opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@
99
Default: "false"
1010
"""
1111

12+
OTEL_CONFIG_FILE = "OTEL_CONFIG_FILE"
13+
"""
14+
.. envvar:: OTEL_CONFIG_FILE
15+
16+
The :envvar:`OTEL_CONFIG_FILE` environment variable points the SDK at a
17+
declarative configuration file (YAML or JSON). When set, the file is the
18+
sole source for SDK construction. Spec-defined ``OTEL_*`` variables with
19+
schema equivalents are ignored. Env vars may still be read indirectly by
20+
components the file enables (e.g. resource detectors) and via
21+
``${env:VAR}`` substitution inside the file. See the OpenTelemetry
22+
declarative configuration specification for details.
23+
"""
24+
1225
OTEL_RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES"
1326
"""
1427
.. envvar:: OTEL_RESOURCE_ATTRIBUTES
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# Tests access private members of SDK classes to assert correct configuration.
5+
# pylint: disable=protected-access,no-self-use
6+
7+
import unittest
8+
from unittest.mock import patch
9+
10+
from opentelemetry.sdk._configuration import _OTelSDKConfigurator
11+
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
12+
from opentelemetry.sdk.environment_variables import OTEL_CONFIG_FILE
13+
14+
15+
class TestConfiguratorFileRouting(unittest.TestCase):
16+
def tearDown(self):
17+
# _BaseConfigurator caches instances via a singleton; reset so sibling
18+
# tests (e.g. test_configurator.py's CustomConfigurator subclass) are
19+
# not affected by this class's singleton state.
20+
_OTelSDKConfigurator._instance = None
21+
22+
@patch.dict("os.environ", {}, clear=True)
23+
@patch("opentelemetry.sdk._configuration._initialize_components")
24+
def test_env_var_unset_runs_env_var_path(self, mock_init_components):
25+
_OTelSDKConfigurator()._configure(auto_instrumentation_version="X")
26+
mock_init_components.assert_called_once_with(
27+
auto_instrumentation_version="X"
28+
)
29+
30+
@patch.dict("os.environ", {OTEL_CONFIG_FILE: "/tmp/otel.yaml"})
31+
@patch("opentelemetry.sdk._configuration._sdk.configure_sdk")
32+
@patch("opentelemetry.sdk._configuration.file._loader.load_config_file")
33+
@patch("opentelemetry.sdk._configuration._initialize_components")
34+
def test_env_var_set_routes_to_declarative_path(
35+
self, mock_init_components, mock_load, mock_configure_sdk
36+
):
37+
sentinel_config = object()
38+
mock_load.return_value = sentinel_config
39+
40+
_OTelSDKConfigurator()._configure()
41+
42+
mock_load.assert_called_once_with("/tmp/otel.yaml")
43+
mock_configure_sdk.assert_called_once_with(sentinel_config)
44+
mock_init_components.assert_not_called()
45+
46+
@patch.dict("os.environ", {OTEL_CONFIG_FILE: "/does/not/exist.yaml"})
47+
@patch("opentelemetry.sdk._configuration._initialize_components")
48+
def test_env_var_set_missing_file_propagates(self, mock_init_components):
49+
with self.assertRaises(ConfigurationError):
50+
_OTelSDKConfigurator()._configure()
51+
mock_init_components.assert_not_called()
52+
53+
@patch.dict("os.environ", {OTEL_CONFIG_FILE: "/tmp/otel.yaml"})
54+
@patch("opentelemetry.sdk._configuration._sdk.configure_sdk")
55+
@patch("opentelemetry.sdk._configuration.file._loader.load_config_file")
56+
def test_env_var_set_with_kwargs_warns_and_ignores(
57+
self, mock_load, mock_configure_sdk
58+
):
59+
mock_load.return_value = object()
60+
61+
with self.assertLogs(
62+
"opentelemetry.sdk._configuration", level="WARNING"
63+
) as captured:
64+
_OTelSDKConfigurator()._configure(
65+
sampler="X", auto_instrumentation_version="Y"
66+
)
67+
68+
self.assertTrue(
69+
any(
70+
"OTEL_CONFIG_FILE" in msg and "sampler" in msg
71+
for msg in captured.output
72+
),
73+
f"Expected warning about ignored kwargs, got: {captured.output}",
74+
)
75+
mock_configure_sdk.assert_called_once()
76+
77+
@patch.dict("os.environ", {}, clear=True)
78+
@patch("opentelemetry.sdk._configuration._initialize_components")
79+
def test_distro_override_pattern_still_works(self, mock_init_components):
80+
class CustomConfigurator(_OTelSDKConfigurator):
81+
def _configure(self, **kwargs):
82+
kwargs["sampler"] = "TEST_SAMPLER"
83+
super()._configure(**kwargs)
84+
85+
CustomConfigurator()._configure(auto_instrumentation_version="V")
86+
87+
mock_init_components.assert_called_once_with(
88+
auto_instrumentation_version="V", sampler="TEST_SAMPLER"
89+
)

0 commit comments

Comments
 (0)