-
Notifications
You must be signed in to change notification settings - Fork 817
config: load and validate generated config models #4898
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
xrmx
merged 29 commits into
open-telemetry:main
from
honeycombio:mike/otel-config/1-loading-validation
Mar 11, 2026
+4,326
−749
Merged
Changes from all commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
43f3fe5
config: generate model code from json schema
codeboten 72c3729
update tox command’s deps and allowlist
MikeGoldsmith a2dfa41
add use-union-operator to datamodel-codegen and regenerate models file
MikeGoldsmith 659ab65
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
MikeGoldsmith dd6a2cd
add changelog
MikeGoldsmith 99e9570
disable union-operator and set target python to 3.10
MikeGoldsmith 43f2c09
Merge pull request #13 from honeycombio/codeboten/generate-config-mod…
codeboten 1a3bde8
ignore generated file for linting
MikeGoldsmith 6936e54
fix TypeAlias import for python 3.9 in generated models file
MikeGoldsmith 7949792
update uv.lock with new dev dependencies
MikeGoldsmith 9f5f21e
Merge pull request #14 from honeycombio/codeboten/generate-config-mod…
codeboten 81ac395
run precommit
codeboten 2b25010
config: add yam/json file loading and env var substitution
MikeGoldsmith 45bc753
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
MikeGoldsmith 65b2d16
Merge branch 'codeboten/generate-config-model-from-schema' of github.…
MikeGoldsmith 801a425
Fix pyright config structure in pyproject.toml
MikeGoldsmith e8180f2
Merge branch 'codeboten/generate-config-model-from-schema' of github.…
MikeGoldsmith 4c24e59
Fix typecheck and pylint errors
MikeGoldsmith dea74e9
Add types-PyYAML and file-configuration extra to typecheck
MikeGoldsmith df082ac
run precommit
MikeGoldsmith 3b704c6
Merge upstream/main into mike/otel-config/1-loading-validation
MikeGoldsmith 9778ae5
config: add jsonschema validation and vendor OTel config schema
MikeGoldsmith 2a122da
config: fix pylint warnings in _loader.py
MikeGoldsmith 3998be7
fix: bump typing_extensions to 4.12.0 for Python 3.13+ compat
MikeGoldsmith 09802fe
merge upstream/main into mike/otel-config/1-loading-validation
MikeGoldsmith 131632e
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
MikeGoldsmith afccaed
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
MikeGoldsmith 7789e6e
config: address review feedback - simplify env substitution and bump …
MikeGoldsmith 3d87d7e
fix: downgrade jsonschema to 4.25.1 for Python 3.9 compatibility
MikeGoldsmith File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
41 changes: 41 additions & 0 deletions
41
opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # 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. | ||
|
|
||
| """OpenTelemetry SDK File Configuration. | ||
|
|
||
| This module provides support for configuring the OpenTelemetry SDK | ||
| using declarative configuration files (YAML or JSON). | ||
|
|
||
| Example: | ||
| >>> from opentelemetry.sdk._configuration.file import load_config_file | ||
| >>> config = load_config_file("otel-config.yaml") | ||
| >>> print(config.file_format) | ||
| '1.0-rc.3' | ||
MikeGoldsmith marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """ | ||
|
|
||
| from opentelemetry.sdk._configuration.file._env_substitution import ( | ||
| EnvSubstitutionError, | ||
| substitute_env_vars, | ||
| ) | ||
| from opentelemetry.sdk._configuration.file._loader import ( | ||
| ConfigurationError, | ||
| load_config_file, | ||
| ) | ||
|
|
||
| __all__ = [ | ||
| "load_config_file", | ||
| "substitute_env_vars", | ||
| "ConfigurationError", | ||
| "EnvSubstitutionError", | ||
| ] | ||
86 changes: 86 additions & 0 deletions
86
opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_env_substitution.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| # 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. | ||
|
|
||
| """Environment variable substitution for configuration files.""" | ||
|
|
||
| import logging | ||
| import os | ||
| import re | ||
|
|
||
| _logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class EnvSubstitutionError(Exception): | ||
| """Raised when environment variable substitution fails. | ||
|
|
||
| This occurs when a ${VAR} reference is found but the environment | ||
| variable is not set and no default value is provided. | ||
| """ | ||
|
|
||
|
|
||
| def substitute_env_vars(text: str) -> str: | ||
| """Substitute environment variables in configuration text. | ||
|
|
||
| Supports the following syntax: | ||
| - ${VAR}: Substitute with environment variable VAR. Raises error if not found. | ||
| - ${VAR:-default}: Substitute with VAR if set, otherwise use default value. | ||
| - $$: Escape sequence for literal $. | ||
|
|
||
| Args: | ||
| text: Configuration text with potential ${VAR} placeholders. | ||
|
|
||
| Returns: | ||
| Text with environment variables substituted. | ||
|
|
||
| Raises: | ||
| EnvSubstitutionError: If a required environment variable is not found. | ||
|
|
||
| Examples: | ||
| >>> os.environ['SERVICE_NAME'] = 'my-service' | ||
| >>> substitute_env_vars('name: ${SERVICE_NAME}') | ||
| 'name: my-service' | ||
| >>> substitute_env_vars('name: ${MISSING:-default}') | ||
| 'name: default' | ||
| >>> substitute_env_vars('price: $$100') | ||
| 'price: $100' | ||
| """ | ||
| # Pattern matches $$ (escape sequence) or ${VAR_NAME} / ${VAR_NAME:-default_value} | ||
| # Handling both in a single pass ensures $$ followed by ${VAR} works correctly | ||
| pattern = r"\$\$|\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}" | ||
|
|
||
| def replace_var(match) -> str: | ||
| if match.group(1) is None: | ||
| # Matched $$, return literal $ | ||
| return "$" | ||
|
|
||
| var_name = match.group(1) | ||
| has_default = match.group(2) is not None | ||
| default_value = match.group(3) if has_default else None | ||
|
|
||
| value = os.environ.get(var_name) | ||
|
|
||
| if value is None: | ||
| if has_default: | ||
| return default_value or "" | ||
| _logger.error( | ||
| "Environment variable '%s' not found and no default provided", | ||
| var_name, | ||
| ) | ||
| raise EnvSubstitutionError( | ||
| f"Environment variable '{var_name}' not found and no default provided" | ||
| ) | ||
|
|
||
| return value | ||
|
|
||
| return re.sub(pattern, replace_var, text) |
223 changes: 223 additions & 0 deletions
223
opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,223 @@ | ||
| # 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. | ||
|
|
||
| """Configuration file loading and parsing.""" | ||
|
|
||
| import importlib.resources | ||
| import json | ||
| import logging | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| from opentelemetry.sdk._configuration.file._env_substitution import ( | ||
| substitute_env_vars, | ||
| ) | ||
| from opentelemetry.sdk._configuration.models import OpenTelemetryConfiguration | ||
|
|
||
| try: | ||
| import yaml | ||
| except ImportError as exc: | ||
| raise ImportError( | ||
| "File configuration requires pyyaml. " | ||
| "Install with: pip install opentelemetry-sdk[file-configuration]" | ||
| ) from exc | ||
|
|
||
| try: | ||
| import jsonschema | ||
| except ImportError as exc: | ||
| raise ImportError( | ||
| "File configuration requires jsonschema. " | ||
| "Install with: pip install opentelemetry-sdk[file-configuration]" | ||
| ) from exc | ||
|
|
||
| _schema_cache: list[dict] = [] | ||
|
|
||
|
|
||
| def _get_schema() -> dict: | ||
| if not _schema_cache: | ||
| schema_path = ( | ||
| importlib.resources.files("opentelemetry.sdk._configuration") | ||
| / "schema.json" | ||
| ) | ||
| _schema_cache.append( | ||
| json.loads(schema_path.read_text(encoding="utf-8")) | ||
| ) | ||
| return _schema_cache[0] | ||
|
|
||
|
|
||
| _logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class ConfigurationError(Exception): | ||
| """Raised when configuration file loading, parsing, or validation fails. | ||
|
|
||
| This includes errors from: | ||
| - File not found or inaccessible | ||
| - Invalid YAML/JSON syntax | ||
| - Schema validation failures | ||
| - Environment variable substitution errors | ||
| """ | ||
|
|
||
|
|
||
| def load_config_file(file_path: str) -> OpenTelemetryConfiguration: | ||
| """Load and parse an OpenTelemetry configuration file. | ||
|
|
||
| Supports YAML and JSON formats. Performs environment variable substitution | ||
| before parsing. | ||
|
|
||
| Args: | ||
| file_path: Path to the configuration file (.yaml, .yml, or .json). | ||
|
|
||
| Returns: | ||
| Parsed OpenTelemetryConfiguration object. | ||
|
|
||
| Raises: | ||
| ConfigurationError: If file cannot be read, parsed, or validated. | ||
| EnvSubstitutionError: If required environment variable is missing. | ||
|
|
||
| Examples: | ||
| >>> config = load_config_file("otel-config.yaml") | ||
| >>> print(config.tracer_provider) | ||
| """ | ||
| path = Path(file_path) | ||
|
|
||
| if not path.exists(): | ||
| _logger.error("Configuration file not found: %s", file_path) | ||
| raise ConfigurationError(f"Configuration file not found: {file_path}") | ||
|
|
||
| if not path.is_file(): | ||
| _logger.error("Configuration path is not a file: %s", file_path) | ||
| raise ConfigurationError( | ||
| f"Configuration path is not a file: {file_path}" | ||
| ) | ||
|
|
||
| try: | ||
| with open(path, encoding="utf-8") as config_file: | ||
| content = config_file.read() | ||
| except (OSError, IOError) as exc: | ||
| _logger.exception("Failed to read configuration file: %s", file_path) | ||
| raise ConfigurationError( | ||
| f"Failed to read configuration file: {file_path}" | ||
| ) from exc | ||
|
|
||
| # Perform environment variable substitution | ||
| try: | ||
| content = substitute_env_vars(content) | ||
| except Exception as exc: | ||
| raise ConfigurationError( | ||
| f"Environment variable substitution failed: {exc}" | ||
| ) from exc | ||
|
|
||
| # Parse based on file extension | ||
| suffix = path.suffix.lower() | ||
| try: | ||
| if suffix in (".yaml", ".yml"): | ||
| data = yaml.safe_load(content) | ||
| elif suffix == ".json": | ||
| data = json.loads(content) | ||
| else: | ||
| _logger.error("Unsupported file format: %s", suffix) | ||
| raise ConfigurationError( | ||
| f"Unsupported file format: {suffix}. Use .yaml, .yml, or .json" | ||
| ) | ||
| except yaml.YAMLError as exc: | ||
| _logger.exception("Failed to parse YAML from %s", file_path) | ||
| raise ConfigurationError(f"Failed to parse YAML: {exc}") from exc | ||
| except json.JSONDecodeError as exc: | ||
| _logger.exception("Failed to parse JSON from %s", file_path) | ||
| raise ConfigurationError(f"Failed to parse JSON: {exc}") from exc | ||
|
|
||
| if data is None: | ||
| _logger.error("Configuration file is empty: %s", file_path) | ||
| raise ConfigurationError("Configuration file is empty") | ||
|
|
||
| if not isinstance(data, dict): | ||
| _logger.error( | ||
| "Configuration must be a mapping/object, got %s", | ||
| type(data).__name__, | ||
| ) | ||
| raise ConfigurationError( | ||
| f"Configuration must be a mapping/object, got {type(data).__name__}" | ||
| ) | ||
|
|
||
| _validate_schema(data) | ||
|
|
||
| # Convert to OpenTelemetryConfiguration model | ||
| try: | ||
| config = _dict_to_model(data) | ||
| except Exception as exc: | ||
| _logger.exception( | ||
| "Failed to validate configuration from %s", file_path | ||
| ) | ||
| raise ConfigurationError( | ||
| f"Failed to validate configuration: {exc}" | ||
| ) from exc | ||
|
|
||
| return config | ||
|
|
||
|
|
||
| def _validate_schema(data: dict) -> None: | ||
| """Validate configuration dict against the OTel configuration JSON schema. | ||
|
|
||
| Raises: | ||
| ConfigurationError: If the data does not conform to the schema. | ||
| """ | ||
| try: | ||
| jsonschema.validate( | ||
| instance=data, | ||
| schema=_get_schema(), | ||
| cls=jsonschema.Draft202012Validator, | ||
| ) | ||
| except jsonschema.ValidationError as exc: | ||
| raise ConfigurationError( | ||
| f"Configuration does not match schema: {exc.message} " | ||
| f"(at {' -> '.join(str(p) for p in exc.absolute_path)})" | ||
| if exc.absolute_path | ||
| else f"Configuration does not match schema: {exc.message}" | ||
| ) from exc | ||
| except jsonschema.SchemaError as exc: | ||
| raise ConfigurationError( | ||
| f"Invalid configuration schema: {exc.message}" | ||
| ) from exc | ||
|
|
||
|
|
||
| def _dict_to_model(data: dict[str, Any]) -> OpenTelemetryConfiguration: | ||
| """Convert dictionary to OpenTelemetryConfiguration model. | ||
|
|
||
| Uses the generated dataclass from models.py. This provides basic | ||
| validation through dataclass field types. | ||
|
|
||
| Args: | ||
| data: Parsed configuration dictionary. | ||
|
|
||
| Returns: | ||
| OpenTelemetryConfiguration instance. | ||
|
|
||
| Raises: | ||
| TypeError: If data doesn't match expected structure. | ||
| ValueError: If values are invalid. | ||
| """ | ||
| # Construct the top-level model from the validated dict. Nested fields | ||
| # are stored as dicts rather than their dataclass types; factory functions | ||
| # in later PRs will handle the full recursive conversion when building | ||
| # SDK objects. | ||
| try: | ||
| config = OpenTelemetryConfiguration(**data) | ||
| return config | ||
| except TypeError as exc: | ||
| # Provide more helpful error message | ||
| raise TypeError( | ||
| f"Configuration structure is invalid. " | ||
| f"Check that all required fields are present and correctly typed: {exc}" | ||
| ) from exc |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.