Skip to content

Commit 5c26f00

Browse files
committed
config: add resource and propagator creation from declarative config
Implements create_resource() and create_propagator()/configure_propagator() for the declarative file configuration. Resource creation does not read OTEL_RESOURCE_ATTRIBUTES or run any detectors (matches Java/JS SDK behavior). Propagator configuration always calls set_global_textmap to override Python's default tracecontext+baggage, setting a noop CompositePropagator when no propagator is configured. Assisted-by: Claude Sonnet 4.6
1 parent 28b6852 commit 5c26f00

File tree

8 files changed

+841
-9
lines changed

8 files changed

+841
-9
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212

1313
## Unreleased
1414

15+
- `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
16+
([#XXXX](https://github.com/open-telemetry/opentelemetry-python/pull/XXXX))
1517
- `opentelemetry-sdk`: Add file configuration support with YAML/JSON loading, environment variable substitution, and schema validation against the vendored OTel config JSON schema
1618
([#4898](https://github.com/open-telemetry/opentelemetry-python/pull/4898))
1719
- 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
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
class ConfigurationError(Exception):
17+
"""Raised when configuration loading, parsing, validation, or instantiation fails.
18+
19+
This includes errors from:
20+
- File not found or inaccessible
21+
- Invalid YAML/JSON syntax
22+
- Schema validation failures
23+
- Environment variable substitution errors
24+
- Missing required SDK extensions (e.g., propagator packages not installed)
25+
"""
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import logging
18+
from typing import Optional
19+
20+
from opentelemetry.baggage.propagation import W3CBaggagePropagator
21+
from opentelemetry.propagate import set_global_textmap
22+
from opentelemetry.propagators.composite import CompositePropagator
23+
from opentelemetry.propagators.textmap import TextMapPropagator
24+
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
25+
from opentelemetry.sdk._configuration.models import (
26+
Propagator as PropagatorConfig,
27+
TextMapPropagator as TextMapPropagatorConfig,
28+
)
29+
from opentelemetry.trace.propagation.tracecontext import (
30+
TraceContextTextMapPropagator,
31+
)
32+
from opentelemetry.util._importlib_metadata import entry_points
33+
34+
_logger = logging.getLogger(__name__)
35+
36+
37+
def _load_entry_point_propagator(name: str) -> TextMapPropagator:
38+
"""Load a propagator by name from the opentelemetry_propagator entry point group."""
39+
try:
40+
eps = list(
41+
entry_points(group="opentelemetry_propagator", name=name)
42+
)
43+
if not eps:
44+
raise ConfigurationError(
45+
f"Propagator '{name}' not found. "
46+
"It may not be installed or may be misspelled."
47+
)
48+
return eps[0].load()()
49+
except ConfigurationError:
50+
raise
51+
except Exception as e:
52+
raise ConfigurationError(
53+
f"Failed to load propagator '{name}': {e}"
54+
) from e
55+
56+
57+
def _propagators_from_textmap_config(
58+
config: TextMapPropagatorConfig,
59+
) -> list[TextMapPropagator]:
60+
"""Resolve a single TextMapPropagator config entry to a list of propagators."""
61+
result: list[TextMapPropagator] = []
62+
if config.tracecontext is not None:
63+
result.append(TraceContextTextMapPropagator())
64+
if config.baggage is not None:
65+
result.append(W3CBaggagePropagator())
66+
if config.b3 is not None:
67+
result.append(_load_entry_point_propagator("b3"))
68+
if config.b3multi is not None:
69+
result.append(_load_entry_point_propagator("b3multi"))
70+
return result
71+
72+
73+
def create_propagator(
74+
config: Optional[PropagatorConfig],
75+
) -> CompositePropagator:
76+
"""Create a CompositePropagator from declarative config.
77+
78+
If config is None or has no propagators defined, returns an empty
79+
CompositePropagator (no-op), ensuring "what you see is what you get"
80+
semantics — the env-var-based default propagators are not used.
81+
82+
Args:
83+
config: Propagator config from the parsed config file, or None.
84+
85+
Returns:
86+
A CompositePropagator wrapping all configured propagators.
87+
"""
88+
if config is None:
89+
return CompositePropagator([])
90+
91+
propagators: list[TextMapPropagator] = []
92+
seen_types: set[type] = set()
93+
94+
def _add_deduped(p: TextMapPropagator) -> None:
95+
if type(p) not in seen_types:
96+
seen_types.add(type(p))
97+
propagators.append(p)
98+
99+
# Process structured composite list
100+
if config.composite:
101+
for entry in config.composite:
102+
for p in _propagators_from_textmap_config(entry):
103+
_add_deduped(p)
104+
105+
# Process composite_list (comma-separated propagator names via entry_points)
106+
if config.composite_list:
107+
for name in config.composite_list.split(","):
108+
name = name.strip()
109+
if not name or name.lower() == "none":
110+
continue
111+
p = _load_entry_point_propagator(name)
112+
_add_deduped(p)
113+
114+
return CompositePropagator(propagators)
115+
116+
117+
def configure_propagator(config: Optional[PropagatorConfig]) -> None:
118+
"""Configure the global text map propagator from declarative config.
119+
120+
Always calls set_global_textmap to override any defaults (including the
121+
env-var-based tracecontext+baggage default set by the SDK).
122+
123+
Args:
124+
config: Propagator config from the parsed config file, or None.
125+
"""
126+
set_global_textmap(create_propagator(config))
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import logging
18+
from typing import Optional
19+
from urllib import parse
20+
21+
from opentelemetry.sdk._configuration.models import (
22+
AttributeNameValue,
23+
AttributeType,
24+
Resource as ResourceConfig,
25+
)
26+
from opentelemetry.sdk.resources import (
27+
SERVICE_NAME,
28+
TELEMETRY_SDK_LANGUAGE,
29+
TELEMETRY_SDK_NAME,
30+
TELEMETRY_SDK_VERSION,
31+
Resource,
32+
_OPENTELEMETRY_SDK_VERSION,
33+
)
34+
35+
_logger = logging.getLogger(__name__)
36+
37+
38+
def _coerce_attribute_value(attr: AttributeNameValue) -> object:
39+
"""Coerce an attribute value to the correct Python type based on AttributeType."""
40+
value = attr.value
41+
attr_type = attr.type
42+
43+
if attr_type is None:
44+
return value
45+
46+
if attr_type == AttributeType.string:
47+
return str(value)
48+
if attr_type == AttributeType.bool:
49+
if isinstance(value, str):
50+
return value.lower() not in ("false", "0", "")
51+
return bool(value)
52+
if attr_type == AttributeType.int:
53+
return int(value) # type: ignore[arg-type]
54+
if attr_type == AttributeType.double:
55+
return float(value) # type: ignore[arg-type]
56+
if attr_type == AttributeType.string_array:
57+
return [str(v) for v in value] # type: ignore[union-attr]
58+
if attr_type == AttributeType.bool_array:
59+
return [bool(v) for v in value] # type: ignore[union-attr]
60+
if attr_type == AttributeType.int_array:
61+
return [int(v) for v in value] # type: ignore[union-attr,arg-type]
62+
if attr_type == AttributeType.double_array:
63+
return [float(v) for v in value] # type: ignore[union-attr,arg-type]
64+
65+
return value
66+
67+
68+
def _parse_attributes_list(attributes_list: str) -> dict[str, str]:
69+
"""Parse a comma-separated key=value string into a dict.
70+
71+
Format is the same as OTEL_RESOURCE_ATTRIBUTES: key=value,key=value
72+
Values are always strings (no type coercion).
73+
"""
74+
result: dict[str, str] = {}
75+
for item in attributes_list.split(","):
76+
item = item.strip()
77+
if not item:
78+
continue
79+
if "=" not in item:
80+
_logger.warning(
81+
"Invalid resource attribute pair in attributes_list: %s",
82+
item,
83+
)
84+
continue
85+
key, value = item.split("=", maxsplit=1)
86+
result[key.strip()] = parse.unquote(value.strip())
87+
return result
88+
89+
90+
def _sdk_default_attributes() -> dict[str, object]:
91+
"""Return the SDK telemetry attributes (equivalent to Java's Resource.getDefault())."""
92+
return {
93+
TELEMETRY_SDK_LANGUAGE: "python",
94+
TELEMETRY_SDK_NAME: "opentelemetry",
95+
TELEMETRY_SDK_VERSION: _OPENTELEMETRY_SDK_VERSION,
96+
}
97+
98+
99+
def create_resource(config: Optional[ResourceConfig]) -> Resource:
100+
"""Create an SDK Resource from declarative config.
101+
102+
Does NOT read OTEL_RESOURCE_ATTRIBUTES or run any resource detectors.
103+
Starts from SDK telemetry defaults (telemetry.sdk.*) and merges config
104+
attributes on top, matching Java SDK behavior.
105+
106+
Args:
107+
config: Resource config from the parsed config file, or None.
108+
109+
Returns:
110+
A Resource with SDK defaults merged with any config-specified attributes.
111+
"""
112+
base = Resource(_sdk_default_attributes())
113+
114+
if config is None:
115+
service_resource = Resource({SERVICE_NAME: "unknown_service"})
116+
return base.merge(service_resource)
117+
118+
# Build attributes from config.attributes list
119+
config_attrs: dict[str, object] = {}
120+
if config.attributes:
121+
for attr in config.attributes:
122+
config_attrs[attr.name] = _coerce_attribute_value(attr)
123+
124+
# Parse attributes_list (key=value,key=value string format)
125+
if config.attributes_list:
126+
list_attrs = _parse_attributes_list(config.attributes_list)
127+
# attributes_list entries do not override explicit attributes
128+
for k, v in list_attrs.items():
129+
if k not in config_attrs:
130+
config_attrs[k] = v
131+
132+
schema_url = config.schema_url
133+
134+
config_resource = Resource(config_attrs, schema_url) # type: ignore[arg-type]
135+
result = base.merge(config_resource)
136+
137+
# Add default service.name if not specified (matches Java SDK behavior)
138+
if not result.attributes.get(SERVICE_NAME):
139+
result = result.merge(Resource({SERVICE_NAME: "unknown_service"}))
140+
141+
return result

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
'1.0'
2525
"""
2626

27+
from opentelemetry.sdk._configuration._propagator import (
28+
configure_propagator,
29+
create_propagator,
30+
)
31+
from opentelemetry.sdk._configuration._resource import create_resource
2732
from opentelemetry.sdk._configuration.file._env_substitution import (
2833
EnvSubstitutionError,
2934
substitute_env_vars,
@@ -38,4 +43,7 @@
3843
"substitute_env_vars",
3944
"ConfigurationError",
4045
"EnvSubstitutionError",
46+
"create_resource",
47+
"create_propagator",
48+
"configure_propagator",
4149
]

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from pathlib import Path
2121
from typing import Any
2222

23+
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
2324
from opentelemetry.sdk._configuration.file._env_substitution import (
2425
substitute_env_vars,
2526
)
@@ -59,15 +60,8 @@ def _get_schema() -> dict:
5960
_logger = logging.getLogger(__name__)
6061

6162

62-
class ConfigurationError(Exception):
63-
"""Raised when configuration file loading, parsing, or validation fails.
64-
65-
This includes errors from:
66-
- File not found or inaccessible
67-
- Invalid YAML/JSON syntax
68-
- Schema validation failures
69-
- Environment variable substitution errors
70-
"""
63+
# Re-export for backwards compatibility
64+
__all__ = ["ConfigurationError", "load_config_file"]
7165

7266

7367
def load_config_file(file_path: str) -> OpenTelemetryConfiguration:

0 commit comments

Comments
 (0)