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
1 change: 1 addition & 0 deletions .changelog/45.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Declarative configuration: add a pure `create(model)` that builds providers without mutating global state, and a `ConfigProvider`/`ConfigProperties` read view over the instrumentation config
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,26 @@
behaviour may change between minor versions.
"""

from opentelemetry.configuration._config_provider import (
ConfigProperties,
ConfigProvider,
get_config_provider,
set_config_provider,
)
from opentelemetry.configuration._exceptions import ConfigurationError
from opentelemetry.configuration._sdk import configure_sdk
from opentelemetry.configuration._sdk import Providers, configure_sdk, create
from opentelemetry.configuration.file._loader import load_config_file
from opentelemetry.configuration.models import OpenTelemetryConfiguration

__all__ = [
"ConfigProperties",
"ConfigProvider",
"ConfigurationError",
"OpenTelemetryConfiguration",
"Providers",
"configure_sdk",
"create",
"get_config_provider",
"load_config_file",
"set_config_provider",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0

"""Read view over declarative instrumentation configuration.

Implements the spec's ``ConfigProvider`` / ``ConfigProperties`` API
(``configuration/api.md``): a stateless, typed read view over the parsed
``instrumentation`` node of a declarative configuration, plus a global
``ConfigProvider`` that makes it retrievable by instrumentation code.

``ConfigProperties`` wraps a mapping (a parsed sub-tree of the config) and
exposes typed getters that return ``None`` when a key is absent or cannot
be coerced to the requested type, matching the spec's "return null" and
Java's ``DeclarativeConfigProperties`` semantics.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import asdict, is_dataclass
from logging import getLogger
from typing import Any

_logger = getLogger(__name__)


def _node_to_mapping(node: Any) -> dict[str, Any]:
"""Normalize a config node into a plain ``dict`` for typed access.

Dataclass nodes (the parsed model tree) are converted recursively via
``asdict``; mappings are copied as-is. Anything else yields an empty
mapping so the getters uniformly return ``None``.
"""
if node is None:
return {}
if is_dataclass(node) and not isinstance(node, type):
return asdict(node)
if isinstance(node, Mapping):
return dict(node)
return {}


class ConfigProperties:
"""A typed read view over a parsed configuration sub-tree.

Wraps a mapping of configuration keys to values. Typed getters coerce
the stored value to the requested type and return ``None`` when the key
is missing or the value has an incompatible type. ``get_config`` returns
a nested :class:`ConfigProperties` for a sub-mapping, enabling traversal
of the full instrumentation tree.
"""

def __init__(self, properties: Mapping[str, Any] | None = None) -> None:
self._properties: dict[str, Any] = (
dict(properties) if properties is not None else {}
)

def get_string(self, name: str) -> str | None:
"""Return the value of ``name`` as a ``str``, or ``None``."""
value = self._properties.get(name)
return value if isinstance(value, str) else None

def get_bool(self, name: str) -> bool | None:
"""Return the value of ``name`` as a ``bool``, or ``None``."""
value = self._properties.get(name)
return value if isinstance(value, bool) else None

def get_int(self, name: str) -> int | None:
"""Return the value of ``name`` as an ``int``, or ``None``.

``bool`` values are rejected (they are not treated as integers).
"""
value = self._properties.get(name)
if isinstance(value, bool):
return None
return value if isinstance(value, int) else None

def get_float(self, name: str) -> float | None:
"""Return the value of ``name`` as a ``float``, or ``None``.

Accepts ``int`` values (widened to ``float``); rejects ``bool``.
"""
value = self._properties.get(name)
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
return None

def get_config(self, name: str) -> ConfigProperties | None:
"""Return the sub-mapping at ``name`` as :class:`ConfigProperties`.

Returns ``None`` when ``name`` is absent or its value is not a
mapping / dataclass node.
"""
value = self._properties.get(name)
if value is None:
return None
if is_dataclass(value) and not isinstance(value, type):
return ConfigProperties(_node_to_mapping(value))
if isinstance(value, Mapping):
return ConfigProperties(dict(value))
return None

def get_config_list(self, name: str) -> list[ConfigProperties] | None:
"""Return the list at ``name`` as a list of :class:`ConfigProperties`.

Each element must be a mapping / dataclass node; returns ``None``
when ``name`` is absent or is not a list of mappings.
"""
value = self._properties.get(name)
if not isinstance(value, list):
return None
result: list[ConfigProperties] = []
for item in value:
mapping = _node_to_mapping(item)
if not mapping and item is not None:
return None
result.append(ConfigProperties(mapping))
return result

def get_scalar_list(self, name: str, scalar_type: type) -> list | None:
"""Return the sequence at ``name`` as a list of ``scalar_type``.

Elements whose type does not match ``scalar_type`` are dropped
(matching Java's ``getScalarList``). ``bool`` is never treated as an
``int``. Returns ``None`` when ``name`` is absent or is not a list.
"""
value = self._properties.get(name)
if not isinstance(value, list):
return None
result: list = []
for item in value:
if scalar_type is int and isinstance(item, bool):
continue
if (
scalar_type is float
and isinstance(item, int)
and not isinstance(item, bool)
):
result.append(float(item))
continue
if isinstance(item, scalar_type):
result.append(item)
return result

def keys(self) -> list[str]:
"""Return the property keys present in this view."""
return list(self._properties.keys())

def __contains__(self, name: str) -> bool:
return name in self._properties

def __repr__(self) -> str:
return f"ConfigProperties(keys={self.keys()!r})"


class ConfigProvider:
"""Holds the instrumentation :class:`ConfigProperties` for global access."""

def __init__(self, instrumentation_config: ConfigProperties) -> None:
self._instrumentation_config = instrumentation_config

def get_instrumentation_config(self) -> ConfigProperties:
"""Return the read view over the ``instrumentation`` config node."""
return self._instrumentation_config


_CONFIG_PROVIDER: ConfigProvider | None = None


def set_config_provider(config_provider: ConfigProvider) -> None:
"""Set the global :class:`ConfigProvider`.

A warning is logged (and the value overwritten) if one is already set,
matching the "set once" behavior of the other declarative globals.
"""
global _CONFIG_PROVIDER # pylint: disable=global-statement
if _CONFIG_PROVIDER is not None:
_logger.warning(
"Overriding of current ConfigProvider is not allowed once set; "
"overwriting the existing instance."
)
_CONFIG_PROVIDER = config_provider


def get_config_provider() -> ConfigProvider | None:
"""Return the global :class:`ConfigProvider`, or ``None`` if unset."""
return _CONFIG_PROVIDER
126 changes: 107 additions & 19 deletions opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,125 @@

"""Top-level orchestrator for declarative SDK configuration.

Takes a parsed ``OpenTelemetryConfiguration`` and applies it by calling
each per-signal ``configure_*`` factory in order. This is the single
entry point for "apply this config" on the declarative path.
Provides two entry points on the declarative path:

* :func:`create` builds the SDK providers from a parsed
``OpenTelemetryConfiguration`` and returns them **without** touching any
process-global state. It mirrors Java's ``DeclarativeConfiguration.create``.
* :func:`configure_sdk` applies a parsed configuration by delegating to
:func:`create` and then installing the built providers as the process
globals (tracer, meter, logger, propagator, ConfigProvider) and running
instrumentation. This is the "apply this config" entry point.
"""

from __future__ import annotations

from dataclasses import dataclass
from logging import getLogger

from opentelemetry import metrics, trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.configuration._config_provider import (
ConfigProperties,
ConfigProvider,
_node_to_mapping,
set_config_provider,
)
from opentelemetry.configuration._logger_provider import (
configure_logger_provider,
create_logger_provider,
)
from opentelemetry.configuration._meter_provider import (
configure_meter_provider,
create_meter_provider,
)
from opentelemetry.configuration._propagator import configure_propagator
from opentelemetry.configuration._propagator import create_propagator
from opentelemetry.configuration._resource import create_resource
from opentelemetry.configuration._tracer_provider import (
configure_tracer_provider,
create_tracer_provider,
)
from opentelemetry.configuration.instrumentation import (
configure_instrumentation,
)
from opentelemetry.configuration.models import OpenTelemetryConfiguration
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.textmap import TextMapPropagator
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.trace import TracerProvider

_logger = getLogger(__name__)


@dataclass
class Providers:
"""The SDK objects built from a declarative configuration.

A provider is ``None`` when its config section was absent (matching the
spec's "noop default" behavior). The propagator is always built (an empty
:class:`CompositePropagator` when no propagator section is present).
``config_provider`` exposes the instrumentation config as a read view.
"""

tracer_provider: TracerProvider | None
meter_provider: MeterProvider | None
logger_provider: LoggerProvider | None
propagator: TextMapPropagator
config_provider: ConfigProvider


def create(config: OpenTelemetryConfiguration) -> Providers:
"""Build SDK providers from a parsed declarative configuration.

This is a pure builder: it constructs and returns the providers without
mutating any process-global state (no ``set_tracer_provider``, etc.) and
without running instrumentation. Use :func:`configure_sdk` to also install
the result as the process globals.

Sections absent from the config (``None``) yield a ``None`` provider,
matching the spec's "noop default" behavior. The instrumentation config
is always exposed via ``config_provider`` (empty when absent).

Args:
config: Parsed ``OpenTelemetryConfiguration`` (typically from
``load_config_file``).

Returns:
A :class:`Providers` bundle of the built objects.
"""
resource = create_resource(config.resource)
return Providers(
tracer_provider=(
create_tracer_provider(config.tracer_provider, resource)
if config.tracer_provider is not None
else None
),
meter_provider=(
create_meter_provider(config.meter_provider, resource)
if config.meter_provider is not None
else None
),
logger_provider=(
create_logger_provider(config.logger_provider, resource)
if config.logger_provider is not None
else None
),
propagator=create_propagator(config.propagator),
config_provider=ConfigProvider(
ConfigProperties(
_node_to_mapping(config.instrumentation_development)
)
),
)


def configure_sdk(config: OpenTelemetryConfiguration) -> None:
"""Configure the global SDK from a parsed declarative configuration.

Builds a :class:`Resource` from ``config.resource`` and applies it to
each signal provider. Sets the global tracer provider, meter provider,
logger provider, and text map propagator from their respective config
sections. Sections absent from the config (``None``) leave the
corresponding global untouched — matching the spec's "noop default"
behavior.
Delegates to :func:`create` to build the providers, then installs them as
the process globals: sets the global tracer provider, meter provider,
logger provider, text map propagator, and :class:`ConfigProvider` from the
built objects. Sections absent from the config leave the corresponding
global untouched — matching the spec's "noop default" behavior. Finally
runs instrumentation from the ``instrumentation`` section.

Honors the top-level ``disabled`` flag: when true, no globals are set.

Expand All @@ -48,7 +130,7 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None:
``load_config_file``).

Example:
>>> from opentelemetry.configuration.file import (
>>> from opentelemetry.configuration import (
... load_config_file, configure_sdk,
... )
>>> config = load_config_file("otel-config.yaml")
Expand All @@ -60,9 +142,15 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None:
)
return

resource = create_resource(config.resource)
configure_tracer_provider(config.tracer_provider, resource)
configure_meter_provider(config.meter_provider, resource)
configure_logger_provider(config.logger_provider, resource)
configure_propagator(config.propagator)
providers = create(config)

if providers.tracer_provider is not None:
trace.set_tracer_provider(providers.tracer_provider)
if providers.meter_provider is not None:
metrics.set_meter_provider(providers.meter_provider)
if providers.logger_provider is not None:
set_logger_provider(providers.logger_provider)
set_global_textmap(providers.propagator)
set_config_provider(providers.config_provider)

configure_instrumentation(config.instrumentation_development)
Loading
Loading