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
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
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:
"""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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we're treating bool as a special case, but only for scalars. For arrays, we have bool() in the dispatch table (which may not give us what we want). Maybe _coerce_bool could go into the dispatch table for both cases and the if attr_type == AttributeType.bool carveout could be removed.

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]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like comma delimited k=v parsing is done in OTELResourceDetector as well. Can they be consolidated?

"""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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this these same values are passed into a Resource as

_DEFAULT_RESOURCE = Resource(
    {
        TELEMETRY_SDK_LANGUAGE: "python",
        TELEMETRY_SDK_NAME: "opentelemetry",
        TELEMETRY_SDK_VERSION: _OPENTELEMETRY_SDK_VERSION,
    }
)

in opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py
Might be nice to share this definition.

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
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"]


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