Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- `opentelemetry-sdk`: Add `create_resource` and `create_propagator`/`configure_propagator` to declarative file configuration, enabling Resource and propagator instantiation from config files without reading env vars
([#4979](https://github.com/open-telemetry/opentelemetry-python/pull/4979))
- `opentelemetry-sdk`: Add file configuration support with YAML/JSON loading, environment variable substitution, and schema validation against the vendored OTel config JSON schema
([#4898](https://github.com/open-telemetry/opentelemetry-python/pull/4898))
- Fix intermittent CI failures in `getting-started` and `tracecontext` jobs caused by GitHub git CDN SHA propagation lag by installing contrib packages from the already-checked-out local copy instead of a second git clone
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 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.


class ConfigurationError(Exception):
"""Raised when configuration loading, parsing, validation, or instantiation fails.

This includes errors from:
- File not found or inaccessible
- Invalid YAML/JSON syntax
- Schema validation failures
- Environment variable substitution errors
- Missing required SDK extensions (e.g., propagator packages not installed)
"""
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# 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.baggage.propagation import W3CBaggagePropagator
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.models import (
Propagator as PropagatorConfig,
)
from opentelemetry.sdk._configuration.models import (
TextMapPropagator as TextMapPropagatorConfig,
)
from opentelemetry.trace.propagation.tracecontext import (
TraceContextTextMapPropagator,
)
from opentelemetry.util._importlib_metadata import entry_points

_logger = logging.getLogger(__name__)


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:
raise ConfigurationError(
f"Propagator '{name}' not found. "
"It may not be installed or may be misspelled."
)
return eps[0].load()()
except ConfigurationError:
raise
except Exception as exc:
raise ConfigurationError(
f"Failed to load propagator '{name}': {exc}"
) from exc


def _propagators_from_textmap_config(
config: TextMapPropagatorConfig,
) -> list[TextMapPropagator]:
"""Resolve a single TextMapPropagator config entry to a list of propagators."""
result: list[TextMapPropagator] = []
if config.tracecontext is not None:
result.append(TraceContextTextMapPropagator())
if config.baggage is not None:
result.append(W3CBaggagePropagator())
if config.b3 is not None:
result.append(_load_entry_point_propagator("b3"))
if config.b3multi is not None:
result.append(_load_entry_point_propagator("b3multi"))
return result


def create_propagator(
config: Optional[PropagatorConfig],
) -> CompositePropagator:
"""Create a CompositePropagator from declarative config.

If config is None or has no propagators defined, returns an empty
CompositePropagator (no-op), ensuring "what you see is what you get"
semantics — the env-var-based default propagators are not used.

Args:
config: Propagator config from the parsed config file, or None.

Returns:
A CompositePropagator wrapping all configured propagators.
"""
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)

# Process structured composite list
Comment thread
herin049 marked this conversation as resolved.
if config.composite:
for entry in config.composite:
for propagator in _propagators_from_textmap_config(entry):
_add_deduped(propagator)

# Process composite_list (comma-separated propagator names via entry_points)
if config.composite_list:
for name in config.composite_list.split(","):
name = name.strip()
if not name or name.lower() == "none":
continue
_add_deduped(_load_entry_point_propagator(name))

return CompositePropagator(propagators)


def configure_propagator(config: Optional[PropagatorConfig]) -> None:
"""Configure the global text map propagator from declarative config.

Always calls set_global_textmap to override any defaults (including the
env-var-based tracecontext+baggage default set by the SDK).

Args:
config: Propagator config from the parsed config file, or None.
"""
set_global_textmap(create_propagator(config))
150 changes: 150 additions & 0 deletions opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# 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 urllib import parse

from opentelemetry.sdk._configuration.models import (
AttributeNameValue,
AttributeType,
)
from opentelemetry.sdk._configuration.models import Resource as ResourceConfig
from opentelemetry.sdk.resources import (
_OPENTELEMETRY_SDK_VERSION,
SERVICE_NAME,
TELEMETRY_SDK_LANGUAGE,
TELEMETRY_SDK_NAME,
TELEMETRY_SDK_VERSION,
Resource,
)

_logger = logging.getLogger(__name__)

# Dispatch table for scalar type coercions
_SCALAR_COERCIONS = {
AttributeType.string: str,
AttributeType.int: int,
AttributeType.double: float,
}

# Dispatch table for array type coercions
_ARRAY_COERCIONS = {
AttributeType.string_array: str,
AttributeType.bool_array: bool,
AttributeType.int_array: int,
AttributeType.double_array: float,
}


def _coerce_bool(value: object) -> bool:
if isinstance(value, str):
return value.lower() not in ("false", "0", "")
return bool(value)


def _coerce_attribute_value(attr: AttributeNameValue) -> object:
Comment thread
MikeGoldsmith marked this conversation as resolved.
"""Coerce an attribute value to the correct Python type based on AttributeType."""
value = attr.value
attr_type = attr.type

if attr_type is None:
return value
if attr_type == AttributeType.bool:
return _coerce_bool(value)
Comment thread
MikeGoldsmith marked this conversation as resolved.
Outdated
scalar_coercer = _SCALAR_COERCIONS.get(attr_type)
if scalar_coercer is not None:
return scalar_coercer(value) # type: ignore[arg-type]
array_coercer = _ARRAY_COERCIONS.get(attr_type)
if array_coercer is not None:
return [array_coercer(item) for item in value] # type: ignore[union-attr,arg-type]
return value


def _parse_attributes_list(attributes_list: str) -> dict[str, str]:
Comment thread
pmcollins marked this conversation as resolved.
"""Parse a comma-separated key=value string into a dict.

Format is the same as OTEL_RESOURCE_ATTRIBUTES: key=value,key=value
Values are always strings (no type coercion).
"""
result: dict[str, str] = {}
for item in attributes_list.split(","):
item = item.strip()
if not item:
continue
if "=" not in item:
_logger.warning(
"Invalid resource attribute pair in attributes_list: %s",
item,
)
continue
key, value = item.split("=", maxsplit=1)
result[key.strip()] = parse.unquote(value.strip())
return result


def _sdk_default_attributes() -> dict[str, str]:
"""Return the SDK telemetry attributes (equivalent to Java's Resource.getDefault())."""
return {
Comment thread
MikeGoldsmith marked this conversation as resolved.
Outdated
TELEMETRY_SDK_LANGUAGE: "python",
TELEMETRY_SDK_NAME: "opentelemetry",
TELEMETRY_SDK_VERSION: _OPENTELEMETRY_SDK_VERSION,
}


def create_resource(config: Optional[ResourceConfig]) -> Resource:
"""Create an SDK Resource from declarative config.

Does NOT read OTEL_RESOURCE_ATTRIBUTES or run any resource detectors.
Starts from SDK telemetry defaults (telemetry.sdk.*) and merges config
attributes on top, matching Java SDK behavior.

Args:
config: Resource config from the parsed config file, or None.

Returns:
A Resource with SDK defaults merged with any config-specified attributes.
"""
base = Resource(_sdk_default_attributes())

if config is None:
service_resource = Resource({SERVICE_NAME: "unknown_service"})
return base.merge(service_resource)

# Build attributes from config.attributes list
config_attrs: dict[str, object] = {}
if config.attributes:
for attr in config.attributes:
config_attrs[attr.name] = _coerce_attribute_value(attr)

# Parse attributes_list (key=value,key=value string format)
if config.attributes_list:
list_attrs = _parse_attributes_list(config.attributes_list)
# attributes_list entries do not override explicit attributes
Comment thread
MikeGoldsmith marked this conversation as resolved.
Outdated
for attr_key, attr_val in list_attrs.items():
if attr_key not in config_attrs:
config_attrs[attr_key] = attr_val

schema_url = config.schema_url

config_resource = Resource(config_attrs, schema_url) # type: ignore[arg-type]
result = base.merge(config_resource)

# Add default service.name if not specified (matches Java SDK behavior)
if not result.attributes.get(SERVICE_NAME):
result = result.merge(Resource({SERVICE_NAME: "unknown_service"}))

return result
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
'1.0'
"""

from opentelemetry.sdk._configuration._propagator import (
configure_propagator,
create_propagator,
)
from opentelemetry.sdk._configuration._resource import create_resource
from opentelemetry.sdk._configuration.file._env_substitution import (
EnvSubstitutionError,
substitute_env_vars,
Expand All @@ -38,4 +43,7 @@
"substitute_env_vars",
"ConfigurationError",
"EnvSubstitutionError",
"create_resource",
"create_propagator",
"configure_propagator",
]
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from pathlib import Path
from typing import Any

from opentelemetry.sdk._configuration._exceptions import ConfigurationError
from opentelemetry.sdk._configuration.file._env_substitution import (
substitute_env_vars,
)
Expand Down Expand Up @@ -59,15 +60,8 @@ def _get_schema() -> dict:
_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
"""
# Re-export for backwards compatibility
__all__ = ["ConfigurationError", "load_config_file"]
Comment thread
MikeGoldsmith marked this conversation as resolved.
Outdated


def load_config_file(file_path: str) -> OpenTelemetryConfiguration:
Expand Down
Loading
Loading