Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5c26f00
config: add resource and propagator creation from declarative config
MikeGoldsmith Mar 13, 2026
8232012
update changelog with PR number
MikeGoldsmith Mar 13, 2026
8329ae4
fix pylint, pyright and ruff errors in resource/propagator config
MikeGoldsmith Mar 13, 2026
506d816
address review feedback: use _DEFAULT_RESOURCE, fix bool_array coercion
MikeGoldsmith Mar 16, 2026
8232d48
fix linter
MikeGoldsmith Mar 16, 2026
6ed3425
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
MikeGoldsmith Mar 16, 2026
99753f9
address review feedback: single coercion table, simplify attributes m…
MikeGoldsmith Mar 16, 2026
8ba91d8
use Callable type annotation on _array helper
MikeGoldsmith Mar 17, 2026
516aecc
Merge remote-tracking branch 'upstream/main' into mike/config-resourc…
MikeGoldsmith Mar 20, 2026
9cfdcce
add detection infrastructure foundations for resource detectors
MikeGoldsmith Mar 20, 2026
103ff08
move service.name default into base resource
MikeGoldsmith Mar 20, 2026
7f51034
remove unused logging import from _propagator.py
MikeGoldsmith Mar 20, 2026
0a03cbd
add TracerProvider creation from declarative config
MikeGoldsmith Mar 16, 2026
90bc125
add changelog entry for PR #4985
MikeGoldsmith Mar 16, 2026
7f58ff6
fix CI lint/type failures in tracer provider config
MikeGoldsmith Mar 16, 2026
732ed92
fix pylint no-self-use on TestCreateSampler._make_provider
MikeGoldsmith Mar 16, 2026
232b06f
use allowlist for bool coercion in declarative config resource
MikeGoldsmith Mar 18, 2026
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- `opentelemetry-sdk`: Add `create_tracer_provider`/`configure_tracer_provider` to declarative file configuration, enabling TracerProvider instantiation from config files without reading env vars
([#4985](https://github.com/open-telemetry/opentelemetry-python/pull/4985))
- `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,122 @@
# 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

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


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:

Choose a reason for hiding this comment

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

Correct me if I'm wrong, but did we want to silently disable trace context, baggage propagation for an empty propagator section in the config file?

Copy link
Member Author

Choose a reason for hiding this comment

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

The intention here is "what you see is what you get" (WYSIWYG) semantics as defined in the declarative config contributing guide:

implementations should minimize the amount of magic that occurs as a result of the absence of an optional property

So if propagators is absent or empty in the config file, the intent is that no propagators are configured — not that we fall back to the SDK defaults of tracecontext+baggage. WYSIWYG: there are no propagators in the config, you get no propagators.

This is intentional and consistent with how e.g. an absent meter_provider results in a noop meter provider rather than a default one with a periodic reader and OTLP exporter.

"""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))
184 changes: 184 additions & 0 deletions opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# 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 fnmatch
import logging
from typing import Callable, Optional
from urllib import parse

from opentelemetry.sdk._configuration.models import (
AttributeNameValue,
AttributeType,
ExperimentalResourceDetector,
IncludeExclude,
)
from opentelemetry.sdk._configuration.models import Resource as ResourceConfig
from opentelemetry.sdk.resources import (
_DEFAULT_RESOURCE,
SERVICE_NAME,
Resource,
)

_logger = logging.getLogger(__name__)


def _coerce_bool(value: object) -> bool:
if isinstance(value, str):
return value.lower() in ("true", "1")
return bool(value)


def _array(coerce: Callable) -> Callable:
return lambda value: [coerce(item) for item in value]


# Dispatch table mapping AttributeType to its coercion callable
_COERCIONS = {
AttributeType.string: str,
AttributeType.int: int,
AttributeType.double: float,
AttributeType.bool: _coerce_bool,
AttributeType.string_array: _array(str),
AttributeType.int_array: _array(int),
AttributeType.double_array: _array(float),
AttributeType.bool_array: _array(_coerce_bool),
}


def _coerce_attribute_value(attr: AttributeNameValue) -> object:
"""Coerce an attribute value to the correct Python type based on AttributeType."""
coerce = _COERCIONS.get(attr.type) # type: ignore[arg-type]
return coerce(attr.value) if coerce is not None else attr.value # type: ignore[operator]


def _parse_attributes_list(attributes_list: str) -> dict[str, str]:
"""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 create_resource(config: Optional[ResourceConfig]) -> Resource:
"""Create an SDK Resource from declarative config.

Does NOT read OTEL_RESOURCE_ATTRIBUTES. Resource detectors are only run
when explicitly listed under detection_development.detectors in the config.
Starts from SDK telemetry defaults (telemetry.sdk.*), merges any detected
attributes, then merges explicit config attributes on top (highest priority).

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

Returns:
A Resource with SDK defaults, optional detector attributes, and any
config-specified attributes merged in priority order.
"""
# Spec requires service.name to always be present; detectors and explicit
# config attributes can override this default.
base = _DEFAULT_RESOURCE.merge(Resource({SERVICE_NAME: "unknown_service"}))

if config is None:
return base

# attributes_list is lower priority; explicit attributes overwrite conflicts.
config_attrs: dict[str, object] = {}
if config.attributes_list:
config_attrs.update(_parse_attributes_list(config.attributes_list))

if config.attributes:
for attr in config.attributes:
config_attrs[attr.name] = _coerce_attribute_value(attr)

schema_url = config.schema_url

# Run detectors only if detection_development is configured. Collect all
# detected attributes, apply the include/exclude filter, then merge before
# config attributes so explicit values always win.
result = base
if config.detection_development:
detected_attrs: dict[str, object] = {}
if config.detection_development.detectors:
for detector_config in config.detection_development.detectors:
_run_detectors(detector_config, detected_attrs)

filtered = _filter_attributes(
detected_attrs, config.detection_development.attributes
)
if filtered:
result = result.merge(Resource(filtered)) # type: ignore[arg-type]

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


def _run_detectors(
detector_config: ExperimentalResourceDetector,
detected_attrs: dict[str, object],
) -> None:
"""Run any detectors present in a single detector config entry.

Each detector PR adds its own branch here. The detected_attrs dict
is updated in-place; later detectors overwrite earlier ones for the
same key.
"""


def _filter_attributes(
attrs: dict[str, object], filter_config: Optional[IncludeExclude]
) -> dict[str, object]:
"""Filter detected attribute keys using include/exclude glob patterns.

Mirrors other SDK IncludeExcludePredicate.createPatternMatching behaviour:
- No filter config (attributes absent) → include all detected attributes.
- included patterns are checked first; excluded patterns are applied after.
- An empty included list is treated as "include everything".
"""
if filter_config is None:
return attrs

included = filter_config.included
excluded = filter_config.excluded

if not included and not excluded:
return attrs

effective_included = included if included else None # [] → include all

result: dict[str, object] = {}
for key, value in attrs.items():
if effective_included is not None and not any(
fnmatch.fnmatch(key, pat) for pat in effective_included
):
continue
if excluded and any(fnmatch.fnmatch(key, pat) for pat in excluded):
continue
result[key] = value
return result
Loading
Loading