diff --git a/.changelog/5829.changed b/.changelog/5829.changed
new file mode 100644
index 00000000000..d1e9f6f80fa
--- /dev/null
+++ b/.changelog/5829.changed
@@ -0,0 +1 @@
+[BREAKING] `opentelemetry-configuration`: split declarative configuration into `load_config` (Parse) and `create_sdk` (Create) per the configuration spec. `load_config_file` is renamed to `load_config` and the `configure_*_provider`/`configure_propagator` functions are replaced by `create_*` equivalents
diff --git a/opentelemetry-configuration/README.rst b/opentelemetry-configuration/README.rst
index f571a4af9f4..9e15126a98d 100644
--- a/opentelemetry-configuration/README.rst
+++ b/opentelemetry-configuration/README.rst
@@ -14,21 +14,36 @@ OpenTelemetry Python SDK Declarative Configuration
This package implements the `OpenTelemetry declarative configuration
specification `_ for
the Python SDK. It parses a YAML or JSON configuration file (or
-programmatically-constructed configuration model) into typed dataclasses and
-applies the result to the global SDK providers.
+programmatically-constructed configuration model) into typed dataclasses,
+creates the SDK components it describes, and can install them as the global
+SDK providers and propagator.
The standard activation path is the ``OTEL_CONFIG_FILE`` environment variable,
which the SDK's configurator picks up automatically. Set the variable and run
your app under ``opentelemetry-instrument``; no code change is required.
-For programmatic use:
+For programmatic use, configure the global SDK from a file:
.. code-block:: python
- from opentelemetry.configuration import configure_sdk, load_config_file
+ from opentelemetry.configuration import configure_sdk, load_config
- config = load_config_file("otel-config.yaml")
- configure_sdk(config)
+ components = configure_sdk(load_config("otel-config.yaml"))
+
+``configure_sdk`` creates every component before setting any global, so a
+configuration error leaves the global SDK untouched. To create components
+without touching global state, use ``load_config`` and ``create_sdk``:
+
+.. code-block:: python
+
+ from opentelemetry.configuration import create_sdk, load_config
+
+ components = create_sdk(load_config("otel-config.yaml"))
+ tracer = components.tracer_provider.get_tracer(__name__)
+
+Sections omitted from the configuration yield no-op providers (and a no-op
+propagator). With ``disabled: true`` all providers are no-op, while the
+configured propagator is still created.
Installation
------------
diff --git a/opentelemetry-configuration/codegen/README.md b/opentelemetry-configuration/codegen/README.md
index 73b008a2633..8ee9f43fc9c 100644
--- a/opentelemetry-configuration/codegen/README.md
+++ b/opentelemetry-configuration/codegen/README.md
@@ -11,6 +11,12 @@ Extends the default dataclass template to support `additionalProperties` from th
This enables plugin/custom component names to flow through typed dataclasses without a post-processing step.
+Schema types that allow exactly one property (`minProperties: 1` and `maxProperties: 1`, e.g. `Sampler`, `SpanExporter`, `Aggregation`) get the `@_exactly_one_property` decorator, which raises `ValueError` at construction unless exactly one component choice (typed field or additional property) is set.
+
+## `one_of_template_data.py`
+
+datamodel-codegen does not expose `minProperties`/`maxProperties` to templates. This script derives an `exactly_one_property` flag for each such schema type and writes it as `--extra-template-data` for `dataclass.jinja2`.
+
## Usage
Templates are applied automatically when regenerating models:
diff --git a/opentelemetry-configuration/codegen/dataclass.jinja2 b/opentelemetry-configuration/codegen/dataclass.jinja2
index 17b8aecdb50..d6ef7747357 100644
--- a/opentelemetry-configuration/codegen/dataclass.jinja2
+++ b/opentelemetry-configuration/codegen/dataclass.jinja2
@@ -13,6 +13,13 @@
See TestGeneratedModelsHaveAdditionalProperties in test_common.py for
regression tests that guard against changes in these context variables.
+
+ Schema types constrained to exactly one property (minProperties: 1,
+ maxProperties: 1, e.g. Sampler) get @_exactly_one_property, placed above
+ @_additional_properties so it validates after extras are captured. The
+ exactly_one_property flag comes from --extra-template-data generated by
+ one_of_template_data.py, since datamodel-codegen does not expose
+ minProperties/maxProperties to templates.
-#}
{% for decorator in decorators -%}
{{ decorator }}
@@ -24,6 +31,9 @@
{%- endif %}
{%- endfor %}
{%- set has_additional = (additionalProperties is defined and additionalProperties != false) or (additionalPropertiesType is defined) %}
+{%- if exactly_one_property is defined and exactly_one_property %}
+@_exactly_one_property
+{%- endif %}
{%- if has_additional %}
@_additional_properties
{%- endif %}
diff --git a/opentelemetry-configuration/codegen/one_of_template_data.py b/opentelemetry-configuration/codegen/one_of_template_data.py
new file mode 100644
index 00000000000..432f561d763
--- /dev/null
+++ b/opentelemetry-configuration/codegen/one_of_template_data.py
@@ -0,0 +1,38 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+"""Write datamodel-codegen ``--extra-template-data`` for one-of schema types.
+
+The configuration schema expresses "exactly one component choice" (e.g. a
+``Sampler`` is one of ``always_on``, ``always_off``, ...) as
+``minProperties: 1, maxProperties: 1``. datamodel-codegen does not pass those
+keywords to templates, so this script derives a per-model
+``exactly_one_property`` flag from the schema for ``dataclass.jinja2`` to
+consume.
+
+Usage:
+ python one_of_template_data.py SCHEMA_JSON OUTPUT_JSON
+"""
+
+import json
+import sys
+from pathlib import Path
+
+
+def one_of_template_data(schema: dict) -> dict[str, dict[str, bool]]:
+ """Map each one-of ``$defs`` model name to ``{"exactly_one_property": True}``."""
+ return {
+ definition.get("title", name): {"exactly_one_property": True}
+ for name, definition in schema.get("$defs", {}).items()
+ if definition.get("minProperties") == 1 and definition.get("maxProperties") == 1
+ }
+
+
+def main(argv: list[str]) -> None:
+ schema_path, output_path = argv
+ schema = json.loads(Path(schema_path).read_text(encoding="utf-8"))
+ Path(output_path).write_text(json.dumps(one_of_template_data(schema), indent=2, sort_keys=True), encoding="utf-8")
+
+
+if __name__ == "__main__":
+ main(sys.argv[1:])
diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py
index 1d42963269f..d9caecccb27 100644
--- a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py
+++ b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py
@@ -4,41 +4,49 @@
"""OpenTelemetry SDK Declarative Configuration.
This package implements the OpenTelemetry declarative configuration
-specification for the Python SDK. Load a YAML or JSON configuration file
-(or build a configuration model programmatically) and apply it to the
-process-global SDK providers.
+specification for the Python SDK: parse a YAML or JSON configuration file
+into a configuration model, create SDK components from a model or do both
+and install the result as the process-global SDK.
The standard activation path is the ``OTEL_CONFIG_FILE`` environment
variable, which the SDK's configurator picks up automatically. For
-programmatic use:
+programmatic use, configure the global SDK from a file:
->>> from opentelemetry.configuration import (
-... load_config_file,
-... configure_sdk,
-... )
->>> config = load_config_file("otel-config.yaml")
->>> configure_sdk(config)
+>>> from opentelemetry.configuration import configure_sdk, load_config
+>>> components = configure_sdk(load_config("otel-config.yaml"))
+
+Or parse and create components without touching any global state:
+
+>>> from opentelemetry.configuration import create_sdk, load_config
+>>> components = create_sdk(load_config("otel-config.yaml"))
+>>> tracer = components.tracer_provider.get_tracer(__name__)
-Construct a configuration programmatically and apply it:
+A configuration model can also be built in code:
>>> from opentelemetry.configuration import (
... OpenTelemetryConfiguration,
-... configure_sdk,
+... create_sdk,
... )
->>> configure_sdk(OpenTelemetryConfiguration(file_format="1.0-rc.1"))
+>>> components = create_sdk(OpenTelemetryConfiguration(file_format="1.0"))
This package is **experimental**. The API surface, type names, and
-behaviour may change between minor versions.
+behavior may change between minor versions.
"""
from opentelemetry.configuration._exceptions import ConfigurationError
-from opentelemetry.configuration._sdk import configure_sdk
-from opentelemetry.configuration.file._loader import load_config_file
+from opentelemetry.configuration._sdk import (
+ SdkComponents,
+ configure_sdk,
+ create_sdk,
+)
+from opentelemetry.configuration.file._loader import load_config
from opentelemetry.configuration.models import OpenTelemetryConfiguration
__all__ = [
"ConfigurationError",
"OpenTelemetryConfiguration",
+ "SdkComponents",
"configure_sdk",
- "load_config_file",
+ "create_sdk",
+ "load_config",
]
diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_common.py b/opentelemetry-configuration/src/opentelemetry/configuration/_common.py
index f25d0991f15..b98b6532615 100644
--- a/opentelemetry-configuration/src/opentelemetry/configuration/_common.py
+++ b/opentelemetry-configuration/src/opentelemetry/configuration/_common.py
@@ -4,6 +4,7 @@
from __future__ import annotations
import dataclasses
+import functools
import inspect
import logging
from collections.abc import Callable
@@ -75,6 +76,33 @@ def _init(self, **kwargs):
return cls
+def _exactly_one_property(cls):
+ """Decorator for dataclasses whose JSON Schema allows exactly one property.
+
+ The configuration schema models mutually exclusive component choices
+ (e.g. a ``Sampler`` is one of ``always_on``, ``always_off``, ...) with
+ ``minProperties: 1, maxProperties: 1``. Wraps ``__init__`` so that
+ constructing such a model with zero or several choices raises
+ ``ValueError``.
+ """
+ original_init = cls.__init__
+ field_names = tuple(f.name for f in dataclasses.fields(cls))
+
+ @functools.wraps(original_init)
+ def _init(self, *args, **kwargs):
+ original_init(self, *args, **kwargs)
+ chosen = [name for name in field_names if getattr(self, name) is not None]
+ chosen.extend(getattr(self, "additional_properties", {}))
+ if len(chosen) != 1:
+ raise ValueError(
+ f"{cls.__name__} requires exactly one of {sorted(field_names)} "
+ f"or a plugin component; got {len(chosen)}: {chosen}"
+ )
+
+ cls.__init__ = _init
+ return cls
+
+
def load_entry_point(group: str, name: str) -> type:
"""Load a plugin class from an entry point group by name.
@@ -127,9 +155,8 @@ def _resolve_component(
for plugin components found in ``config.additional_properties``.
The JSON schema enforces exactly one component per config block
- (``minProperties: 1, maxProperties: 1``). If multiple typed fields or
- ``additional_properties`` entries are set (e.g. when schema validation
- is bypassed), the first registry match wins.
+ (``minProperties: 1, maxProperties: 1``), and the generated models
+ enforce it at construction via ``@_exactly_one_property``.
Args:
config: A dataclass with ``additional_properties`` (from the
diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py
index 3adeacf9a04..28849929d76 100644
--- a/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py
+++ b/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py
@@ -5,7 +5,6 @@
import logging
-from opentelemetry._logs import set_logger_provider
from opentelemetry.configuration._common import (
_map_compression,
_parse_headers,
@@ -54,6 +53,7 @@
from opentelemetry.configuration.models import (
SimpleLogRecordProcessor as SimpleLogRecordProcessorConfig,
)
+from opentelemetry.metrics import MeterProvider
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs._internal import (
LogRecordLimits,
@@ -86,8 +86,12 @@ def _create_console_log_exporter() -> ConsoleLogRecordExporter:
def _create_otlp_http_log_exporter(
config: OtlpHttpExporterConfig,
+ meter_provider: MeterProvider | None,
) -> LogRecordExporter:
- """Create an OTLP HTTP log exporter from config."""
+ """Create an OTLP HTTP log exporter from config.
+
+ ``meter_provider`` receives the protobuf exporter's own metrics
+ """
headers = _parse_headers(config.headers, config.headers_list)
timeout = (config.timeout / 1000.0) if config.timeout is not None else None
@@ -113,6 +117,7 @@ def _create_otlp_http_log_exporter(
headers=headers,
timeout=timeout,
compression=compression,
+ meter_provider=meter_provider,
)
case OtlpHttpEncoding.json:
try:
@@ -144,6 +149,7 @@ def _create_otlp_http_log_exporter(
def _create_otlp_grpc_log_exporter(
config: OtlpGrpcExporterConfig,
+ meter_provider: MeterProvider | None,
) -> LogRecordExporter:
"""Create an OTLP gRPC log exporter from config."""
try:
@@ -168,6 +174,7 @@ def _create_otlp_grpc_log_exporter(
headers=headers,
timeout=timeout,
compression=compression, # type: ignore[arg-type]
+ meter_provider=meter_provider,
)
@@ -192,6 +199,7 @@ def _create_otlp_file_development_log_exporter(
def _create_log_record_exporter(
config: LogRecordExporterConfig,
+ meter_provider: MeterProvider | None,
) -> LogRecordExporter:
"""Create a log record exporter from config.
@@ -202,9 +210,9 @@ def _create_log_record_exporter(
"""
match config:
case LogRecordExporterConfig(otlp_http=exporter_config) if exporter_config is not None:
- return _create_otlp_http_log_exporter(exporter_config)
+ return _create_otlp_http_log_exporter(exporter_config, meter_provider)
case LogRecordExporterConfig(otlp_grpc=exporter_config) if exporter_config is not None:
- return _create_otlp_grpc_log_exporter(exporter_config)
+ return _create_otlp_grpc_log_exporter(exporter_config, meter_provider)
case LogRecordExporterConfig(console=exporter_config) if exporter_config is not None:
return ConsoleLogRecordExporter()
case LogRecordExporterConfig(otlp_file_development=exporter_config) if exporter_config is not None:
@@ -221,12 +229,13 @@ def _create_log_record_exporter(
def _create_batch_log_record_processor(
config: BatchLogRecordProcessorConfig,
+ meter_provider: MeterProvider | None,
) -> BatchLogRecordProcessor:
"""Create a BatchLogRecordProcessor from config.
Passes explicit defaults to suppress OTEL_BLRP_* env var reading.
"""
- exporter = _create_log_record_exporter(config.exporter)
+ exporter = _create_log_record_exporter(config.exporter, meter_provider)
schedule_delay = config.schedule_delay if config.schedule_delay is not None else _DEFAULT_SCHEDULE_DELAY_MILLIS
export_timeout = config.export_timeout if config.export_timeout is not None else _DEFAULT_EXPORT_TIMEOUT_MILLIS
max_queue_size = config.max_queue_size if config.max_queue_size is not None else _DEFAULT_MAX_QUEUE_SIZE
@@ -239,25 +248,28 @@ def _create_batch_log_record_processor(
export_timeout_millis=float(export_timeout),
max_queue_size=max_queue_size,
max_export_batch_size=max_export_batch_size,
+ meter_provider=meter_provider,
)
def _create_simple_log_record_processor(
config: SimpleLogRecordProcessorConfig,
+ meter_provider: MeterProvider | None,
) -> SimpleLogRecordProcessor:
"""Create a SimpleLogRecordProcessor from config."""
- exporter = _create_log_record_exporter(config.exporter)
- return SimpleLogRecordProcessor(exporter)
+ exporter = _create_log_record_exporter(config.exporter, meter_provider)
+ return SimpleLogRecordProcessor(exporter, meter_provider=meter_provider)
def _create_log_record_processor(
config: LogRecordProcessorConfig,
+ meter_provider: MeterProvider | None,
) -> BatchLogRecordProcessor | SimpleLogRecordProcessor:
"""Create a log record processor from config."""
if config.batch is not None:
- return _create_batch_log_record_processor(config.batch)
+ return _create_batch_log_record_processor(config.batch, meter_provider)
if config.simple is not None:
- return _create_simple_log_record_processor(config.simple)
+ return _create_simple_log_record_processor(config.simple, meter_provider)
raise ConfigurationError(
"No processor type specified in log record processor config. Supported types: batch, simple."
)
@@ -348,6 +360,7 @@ def create_logger_provider(
config: LoggerProviderConfig | None,
resource: Resource | None = None,
global_attribute_limits: AttributeLimits | None = None,
+ meter_provider: MeterProvider | None = None,
) -> LoggerProvider:
"""Create an SDK LoggerProvider from declarative config.
@@ -359,6 +372,9 @@ def create_logger_provider(
resource: Resource to attach to the provider.
global_attribute_limits: Top-level attribute_limits from the root config,
used as a fallback when per-signal limits are not specified.
+ meter_provider: MeterProvider for the SDK's own metrics (provider,
+ processors, and OTLP exporters). When None, those components use
+ the global MeterProvider.
Returns:
A configured LoggerProvider.
@@ -379,6 +395,7 @@ def create_logger_provider(
provider = LoggerProvider(
resource=resource,
+ meter_provider=meter_provider,
log_record_limits=log_record_limits,
_logger_configurator=logger_configurator,
)
@@ -387,27 +404,6 @@ def create_logger_provider(
return provider
for processor_config in config.processors:
- provider.add_log_record_processor(_create_log_record_processor(processor_config))
+ provider.add_log_record_processor(_create_log_record_processor(processor_config, meter_provider))
return provider
-
-
-def configure_logger_provider(
- config: LoggerProviderConfig | None,
- resource: Resource | None = None,
- global_attribute_limits: AttributeLimits | None = None,
-) -> None:
- """Configure the global LoggerProvider from declarative config.
-
- When config is None (logger_provider section absent from config file),
- the global is not set — matching Java/JS SDK behavior.
-
- Args:
- config: LoggerProvider config from the parsed config file, or None.
- resource: Resource to attach to the provider.
- global_attribute_limits: Top-level attribute_limits from the root config,
- used as a fallback when per-signal limits are not specified.
- """
- if config is None:
- return
- set_logger_provider(create_logger_provider(config, resource, global_attribute_limits))
diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py
index 7e816c529ab..13157f17475 100644
--- a/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py
+++ b/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py
@@ -5,7 +5,6 @@
import logging
-from opentelemetry import metrics
from opentelemetry.configuration._common import (
_map_compression,
_parse_headers,
@@ -272,8 +271,13 @@ def _create_console_metric_exporter(
def _create_otlp_http_metric_exporter(
config: OtlpHttpMetricExporterConfig,
+ meter_provider: MeterProvider,
) -> MetricExporter:
- """Create an OTLP HTTP metric exporter from config."""
+ """Create an OTLP HTTP metric exporter from config.
+
+ ``meter_provider`` receives the protobuf exporter's own metrics; the JSON
+ exporter does not record any.
+ """
headers = _parse_headers(config.headers, config.headers_list)
timeout = (config.timeout / 1000.0) if config.timeout is not None else None
preferred_temporality = _map_temporality(config.temporality_preference)
@@ -303,6 +307,7 @@ def _create_otlp_http_metric_exporter(
compression=compression,
preferred_temporality=preferred_temporality,
preferred_aggregation=preferred_aggregation,
+ meter_provider=meter_provider,
)
case OtlpHttpEncoding.json:
try:
@@ -336,6 +341,7 @@ def _create_otlp_http_metric_exporter(
def _create_otlp_grpc_metric_exporter(
config: OtlpGrpcMetricExporterConfig,
+ meter_provider: MeterProvider,
) -> MetricExporter:
"""Create an OTLP gRPC metric exporter from config."""
try:
@@ -364,6 +370,7 @@ def _create_otlp_grpc_metric_exporter(
compression=compression, # type: ignore[arg-type]
preferred_temporality=preferred_temporality,
preferred_aggregation=preferred_aggregation,
+ meter_provider=meter_provider,
)
@@ -401,6 +408,7 @@ def _create_otlp_file_development_metric_exporter(
def _create_push_metric_exporter(
config: PushMetricExporterConfig,
+ meter_provider: MeterProvider,
) -> MetricExporter:
"""Create a push metric exporter from config.
@@ -411,9 +419,9 @@ def _create_push_metric_exporter(
"""
match config:
case PushMetricExporterConfig(otlp_http=exporter_config) if exporter_config is not None:
- return _create_otlp_http_metric_exporter(exporter_config)
+ return _create_otlp_http_metric_exporter(exporter_config, meter_provider)
case PushMetricExporterConfig(otlp_grpc=exporter_config) if exporter_config is not None:
- return _create_otlp_grpc_metric_exporter(exporter_config)
+ return _create_otlp_grpc_metric_exporter(exporter_config, meter_provider)
case PushMetricExporterConfig(console=exporter_config) if exporter_config is not None:
return _create_console_metric_exporter(exporter_config)
case PushMetricExporterConfig(otlp_file_development=exporter_config) if exporter_config is not None:
@@ -430,12 +438,13 @@ def _create_push_metric_exporter(
def _create_periodic_metric_reader(
config: PeriodicMetricReaderConfig,
+ meter_provider: MeterProvider,
) -> PeriodicExportingMetricReader:
"""Create a PeriodicExportingMetricReader from config.
Passes explicit interval/timeout defaults to suppress env var reading.
"""
- exporter = _create_push_metric_exporter(config.exporter)
+ exporter = _create_push_metric_exporter(config.exporter, meter_provider)
interval = config.interval if config.interval is not None else _DEFAULT_EXPORT_INTERVAL_MILLIS
timeout = config.timeout if config.timeout is not None else _DEFAULT_EXPORT_TIMEOUT_MILLIS
return PeriodicExportingMetricReader(
@@ -527,10 +536,13 @@ def _create_pull_metric_reader(
return _create_pull_metric_exporter(config.exporter)
-def _create_metric_reader(config: MetricReaderConfig) -> MetricReader:
- """Create a MetricReader from config."""
+def _create_metric_reader(config: MetricReaderConfig, meter_provider: MeterProvider) -> MetricReader:
+ """Create a MetricReader from config.
+
+ ``meter_provider`` receives the exporter's own metrics.
+ """
if config.periodic is not None:
- return _create_periodic_metric_reader(config.periodic)
+ return _create_periodic_metric_reader(config.periodic, meter_provider)
if config.pull is not None:
return _create_pull_metric_reader(config.pull)
raise ConfigurationError("No reader type specified in metric reader config. Supported types: periodic, pull.")
@@ -594,6 +606,9 @@ def create_meter_provider(
or any other env vars for values explicitly controlled by the config.
Absent config values use OTel spec defaults, matching Java SDK behavior.
+ Readers are added after the provider is created so that their exporters
+ record the SDK's own metrics through it.
+
Args:
config: MeterProvider config from the parsed config file, or None.
resource: Resource to attach to the provider.
@@ -607,15 +622,10 @@ def create_meter_provider(
if config is not None and config.exemplar_filter is not None:
exemplar_filter = _create_exemplar_filter(config.exemplar_filter)
- readers: list[MetricReader] = []
views: list[View] = []
-
- if config is not None:
- for reader_config in config.readers:
- readers.append(_create_metric_reader(reader_config))
- if config.views:
- for view_config in config.views:
- views.append(_create_view(view_config))
+ if config is not None and config.views:
+ for view_config in config.views:
+ views.append(_create_view(view_config))
meter_configurator = (
_create_meter_configurator(config.meter_configurator_development)
@@ -623,28 +633,20 @@ def create_meter_provider(
else None
)
- return MeterProvider(
+ provider = MeterProvider(
resource=resource,
- metric_readers=readers,
exemplar_filter=exemplar_filter, # type: ignore[arg-type]
views=views,
_meter_configurator=meter_configurator,
)
-
-def configure_meter_provider(
- config: MeterProviderConfig | None,
- resource: Resource | None = None,
-) -> None:
- """Configure the global MeterProvider from declarative config.
-
- When config is None (meter_provider section absent from config file),
- the global is not set — matching Java/JS SDK behavior.
-
- Args:
- config: MeterProvider config from the parsed config file, or None.
- resource: Resource to attach to the provider.
- """
- if config is None:
- return
- metrics.set_meter_provider(create_meter_provider(config, resource))
+ if config is not None:
+ try:
+ for reader_config in config.readers:
+ provider.add_metric_reader(_create_metric_reader(reader_config, provider))
+ except Exception:
+ # Shut down readers already added, e.g. a running periodic reader.
+ provider.shutdown()
+ raise
+
+ return provider
diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_propagator.py b/opentelemetry-configuration/src/opentelemetry/configuration/_propagator.py
index d71ce06442c..4d144fc6fc6 100644
--- a/opentelemetry-configuration/src/opentelemetry/configuration/_propagator.py
+++ b/opentelemetry-configuration/src/opentelemetry/configuration/_propagator.py
@@ -11,7 +11,6 @@
from opentelemetry.configuration.models import (
TextMapPropagator as TextMapPropagatorConfig,
)
-from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.propagators.textmap import TextMapPropagator
from opentelemetry.trace.propagation.tracecontext import (
@@ -88,15 +87,3 @@ def create_propagator(
propagators.setdefault(type(propagator), propagator)
return CompositePropagator(list(propagators.values()))
-
-
-def configure_propagator(config: PropagatorConfig | None) -> 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))
diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py
index 12b5081b834..0aee64424bc 100644
--- a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py
+++ b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py
@@ -3,25 +3,37 @@
"""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.
+``create_sdk`` implements the configuration spec's stateless Create
+operation: it interprets a parsed ``OpenTelemetryConfiguration`` and returns
+the top level SDK components without touching any global state.
+``configure_sdk`` creates the components and only then installs them as the
+process global providers and propagator.
"""
from __future__ import annotations
+from dataclasses import dataclass
from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING, getLogger
+from opentelemetry import metrics, trace
+from opentelemetry._logs import (
+ LoggerProvider,
+ NoOpLoggerProvider,
+ set_logger_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.file._loader import (
+ _validate_file_format,
)
from opentelemetry.configuration.instrumentation import (
configure_instrumentation,
@@ -30,6 +42,12 @@
OpenTelemetryConfiguration,
SeverityNumber,
)
+from opentelemetry.propagate import set_global_textmap
+from opentelemetry.propagators.textmap import TextMapPropagator
+from opentelemetry.sdk._logs import LoggerProvider as SdkLoggerProvider
+from opentelemetry.sdk.metrics import MeterProvider as SdkMeterProvider
+from opentelemetry.sdk.resources import Resource
+from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider
_logger = getLogger(__name__)
@@ -64,45 +82,165 @@
}
-def configure_sdk(config: OpenTelemetryConfiguration) -> None:
- """Configure the global SDK from a parsed declarative configuration.
+@dataclass(frozen=True)
+class SdkComponents:
+ """Top level SDK components created from a declarative configuration.
+
+ Providers for sections omitted from the configuration and all providers
+ when the configuration sets ``disabled: true`` are the API no-op
+ implementations.
+ """
+
+ tracer_provider: trace.TracerProvider
+ meter_provider: metrics.MeterProvider
+ logger_provider: LoggerProvider
+ propagator: TextMapPropagator
+ resource: Resource
+
+ def shutdown(self) -> None:
+ """Shut down every SDK provider; no-op providers are skipped."""
+ _shutdown_providers([self.tracer_provider, self.meter_provider, self.logger_provider])
+
+
+def _shutdown_providers(
+ providers: list[trace.TracerProvider | metrics.MeterProvider | LoggerProvider],
+) -> None:
+ """Shut down SDK providers, logging rather than raising on failure."""
+ for provider in providers:
+ if not isinstance(provider, (SdkTracerProvider, SdkMeterProvider, SdkLoggerProvider)):
+ continue
+ try:
+ provider.shutdown()
+ except Exception: # pylint: disable=broad-except
+ _logger.exception("Failed to shut down %s", type(provider).__name__)
+
- 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.
+def create_sdk(config: OpenTelemetryConfiguration) -> SdkComponents:
+ """Create SDK components from a declarative configuration model.
- Honors the top-level ``disabled`` flag: when true, the function returns
- early without setting any globals. The ``log_level`` field, when present
- and not disabled, is applied to the internal ``opentelemetry`` logger to
- control the verbosity of SDK self-diagnostics.
+ Implements the configuration spec's Create operation. This is stateless:
+ no global provider, propagator, or logger level is modified, so the
+ caller decides whether and how to install the returned components.
+
+ Sections omitted from ``config`` yield the API no-op provider (and an
+ empty composite propagator), per the configuration schema's "If omitted,
+ a noop ... is used". When ``config.disabled`` is true, all providers are
+ no-op but the configured propagator and resource are still created, so
+ context keeps propagating through the process.
+
+ The SDK's own metrics (from providers, processors, and OTLP exporters)
+ are recorded through the created meter provider, so creation never reads
+ the global MeterProvider.
+
+ If creating any component fails, providers already created are shut
+ down before the error is re-raised.
Args:
- config: Parsed ``OpenTelemetryConfiguration`` (typically from
- ``load_config_file``).
+ config: Configuration model, from ``load_config`` or built in code.
- Example:
- >>> from opentelemetry.configuration.file import (
- ... load_config_file,
- ... configure_sdk,
- ... )
- >>> config = load_config_file("otel-config.yaml")
- >>> configure_sdk(config)
+ Returns:
+ The created tracer, meter, and logger providers, propagator, and
+ resource.
+
+ Raises:
+ ConfigurationError: If the configuration is invalid or a component
+ cannot be created.
"""
+ _validate_file_format(config.file_format)
+
+ resource = create_resource(config.resource)
+ propagator = create_propagator(config.propagator)
+
if config.disabled:
- _logger.warning("Declarative configuration has disabled=true; skipping SDK setup.")
- return
+ _logger.warning("Declarative configuration has disabled=true; using no-op tracer, meter and logger providers.")
+ return SdkComponents(
+ tracer_provider=trace.NoOpTracerProvider(),
+ meter_provider=metrics.NoOpMeterProvider(),
+ logger_provider=NoOpLoggerProvider(),
+ propagator=propagator,
+ resource=resource,
+ )
+
+ global_attribute_limits = config.attribute_limits
+ created: list[trace.TracerProvider | metrics.MeterProvider | LoggerProvider] = []
+ try:
+ # Created first so the other providers record the SDK's own metrics
+ # through it rather than reading the global MeterProvider.
+ meter_provider: metrics.MeterProvider = metrics.NoOpMeterProvider()
+ if config.meter_provider is not None:
+ meter_provider = create_meter_provider(config.meter_provider, resource)
+ created.append(meter_provider)
+
+ tracer_provider: trace.TracerProvider = trace.NoOpTracerProvider()
+ if config.tracer_provider is not None:
+ tracer_provider = create_tracer_provider(
+ config.tracer_provider,
+ resource,
+ global_attribute_limits,
+ meter_provider=meter_provider,
+ )
+ created.append(tracer_provider)
+
+ logger_provider: LoggerProvider = NoOpLoggerProvider()
+ if config.logger_provider is not None:
+ logger_provider = create_logger_provider(
+ config.logger_provider,
+ resource,
+ global_attribute_limits,
+ meter_provider=meter_provider,
+ )
+ created.append(logger_provider)
+ except Exception:
+ _shutdown_providers(created)
+ raise
+
+ return SdkComponents(
+ tracer_provider=tracer_provider,
+ meter_provider=meter_provider,
+ logger_provider=logger_provider,
+ propagator=propagator,
+ resource=resource,
+ )
+
+
+def configure_sdk(config: OpenTelemetryConfiguration) -> SdkComponents:
+ """Configure the global SDK from a declarative configuration model.
+
+ Creates every component with :func:`create_sdk` before any global is
+ touched, so a creation error leaves the global providers and propagator
+ unchanged. On success, installs the tracer, meter, and logger providers
+ (no-op ones included, so the API never falls back to
+ environment-selected providers) and the text map propagator as globals,
+ applies ``log_level`` to the internal ``opentelemetry`` logger, and,
+ unless the configuration is disabled, activates the instrumentation
+ listed under ``instrumentation/development``.
+
+ Args:
+ config: Configuration model, from ``load_config`` or built in code.
+
+ Returns:
+ The installed SDK components, e.g. for shutting them down.
+
+ Raises:
+ ConfigurationError: If the configuration is invalid or a component
+ cannot be created.
+
+ Example:
+ >>> from opentelemetry.configuration import configure_sdk, load_config
+ >>> components = configure_sdk(load_config("otel-config.yaml"))
+ """
+ components = create_sdk(config)
+
+ trace.set_tracer_provider(components.tracer_provider)
+ metrics.set_meter_provider(components.meter_provider)
+ set_logger_provider(components.logger_provider)
+ set_global_textmap(components.propagator)
if config.log_level is not None:
level = _SEVERITY_TO_LOGGING_LEVEL.get(config.log_level, INFO)
getLogger("opentelemetry").setLevel(level)
- global_attribute_limits = config.attribute_limits
- resource = create_resource(config.resource)
- configure_tracer_provider(config.tracer_provider, resource, global_attribute_limits)
- configure_meter_provider(config.meter_provider, resource)
- configure_logger_provider(config.logger_provider, resource, global_attribute_limits)
- configure_propagator(config.propagator)
- configure_instrumentation(config.instrumentation_development)
+ if not config.disabled:
+ configure_instrumentation(config.instrumentation_development)
+
+ return components
diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py
index c51086924b1..8f8e274892e 100644
--- a/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py
+++ b/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py
@@ -5,7 +5,6 @@
import logging
-from opentelemetry import trace
from opentelemetry.configuration._common import (
_map_compression,
_matches_glob,
@@ -67,6 +66,7 @@
from opentelemetry.configuration.models import (
TracerProvider as TracerProviderConfig,
)
+from opentelemetry.metrics import MeterProvider
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import (
_DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT,
@@ -122,8 +122,13 @@
def _create_otlp_http_span_exporter(
config: OtlpHttpExporterConfig,
+ meter_provider: MeterProvider | None,
) -> SpanExporter:
- """Create an OTLP HTTP span exporter from config."""
+ """Create an OTLP HTTP span exporter from config.
+
+ ``meter_provider`` receives the protobuf exporter's own metrics; the JSON
+ exporter does not record any.
+ """
headers = _parse_headers(config.headers, config.headers_list)
timeout = (config.timeout / 1000.0) if config.timeout is not None else None
@@ -149,6 +154,7 @@ def _create_otlp_http_span_exporter(
headers=headers,
timeout=timeout,
compression=compression,
+ meter_provider=meter_provider,
)
case OtlpHttpEncoding.json:
try:
@@ -180,6 +186,7 @@ def _create_otlp_http_span_exporter(
def _create_otlp_grpc_span_exporter(
config: OtlpGrpcExporterConfig,
+ meter_provider: MeterProvider | None,
) -> SpanExporter:
"""Create an OTLP gRPC span exporter from config."""
try:
@@ -204,6 +211,7 @@ def _create_otlp_grpc_span_exporter(
headers=headers,
timeout=timeout,
compression=compression, # type: ignore[arg-type]
+ meter_provider=meter_provider,
)
@@ -226,7 +234,10 @@ def _create_otlp_file_development_span_exporter(
return FileSpanExporter(path) if path is not None else FileSpanExporter()
-def _create_span_exporter(config: SpanExporterConfig) -> SpanExporter:
+def _create_span_exporter(
+ config: SpanExporterConfig,
+ meter_provider: MeterProvider | None,
+) -> SpanExporter:
"""Create a span exporter from config.
Known exporter types are checked via typed fields on the SpanExporter
@@ -236,9 +247,9 @@ def _create_span_exporter(config: SpanExporterConfig) -> SpanExporter:
"""
match config:
case SpanExporterConfig(otlp_http=exporter_config) if exporter_config is not None:
- return _create_otlp_http_span_exporter(exporter_config)
+ return _create_otlp_http_span_exporter(exporter_config, meter_provider)
case SpanExporterConfig(otlp_grpc=exporter_config) if exporter_config is not None:
- return _create_otlp_grpc_span_exporter(exporter_config)
+ return _create_otlp_grpc_span_exporter(exporter_config, meter_provider)
case SpanExporterConfig(console=exporter_config) if exporter_config is not None:
return ConsoleSpanExporter()
case SpanExporterConfig(otlp_file_development=exporter_config) if exporter_config is not None:
@@ -255,19 +266,24 @@ def _create_span_exporter(config: SpanExporterConfig) -> SpanExporter:
def _create_span_processor(
config: SpanProcessorConfig,
+ meter_provider: MeterProvider | None,
) -> BatchSpanProcessor | SimpleSpanProcessor:
"""Create a span processor from config."""
if config.batch is not None:
- exporter = _create_span_exporter(config.batch.exporter)
+ exporter = _create_span_exporter(config.batch.exporter, meter_provider)
return BatchSpanProcessor(
exporter,
max_queue_size=config.batch.max_queue_size,
schedule_delay_millis=config.batch.schedule_delay,
max_export_batch_size=config.batch.max_export_batch_size,
export_timeout_millis=config.batch.export_timeout,
+ meter_provider=meter_provider,
)
if config.simple is not None:
- return SimpleSpanProcessor(_create_span_exporter(config.simple.exporter))
+ return SimpleSpanProcessor(
+ _create_span_exporter(config.simple.exporter, meter_provider),
+ meter_provider=meter_provider,
+ )
raise ConfigurationError("No processor type specified in span processor config. Supported types: batch, simple.")
@@ -482,6 +498,7 @@ def create_tracer_provider(
config: TracerProviderConfig | None,
resource: Resource | None = None,
global_attribute_limits: AttributeLimits | None = None,
+ meter_provider: MeterProvider | None = None,
) -> TracerProvider:
"""Create an SDK TracerProvider from declarative config.
@@ -494,6 +511,9 @@ def create_tracer_provider(
resource: Resource to attach to the provider.
global_attribute_limits: Top-level attribute_limits from the root config,
used as a fallback when per-signal limits are not specified.
+ meter_provider: MeterProvider for the SDK's own metrics (provider,
+ processors, and OTLP exporters). When None, those components use
+ the global MeterProvider.
Returns:
A configured TracerProvider.
@@ -521,33 +541,12 @@ def create_tracer_provider(
sampler=sampler,
span_limits=span_limits,
id_generator=id_generator,
+ meter_provider=meter_provider,
_tracer_configurator=tracer_configurator,
)
if config is not None:
for proc_config in config.processors:
- provider.add_span_processor(_create_span_processor(proc_config))
+ provider.add_span_processor(_create_span_processor(proc_config, meter_provider))
return provider
-
-
-def configure_tracer_provider(
- config: TracerProviderConfig | None,
- resource: Resource | None = None,
- global_attribute_limits: AttributeLimits | None = None,
-) -> None:
- """Configure the global TracerProvider from declarative config.
-
- When config is None (tracer_provider section absent from config file),
- the global is not set — matching Java/JS SDK behavior and the spec's
- "a noop tracer provider is used" default.
-
- Args:
- config: TracerProvider config from the parsed config file, or None.
- resource: Resource to attach to the provider.
- global_attribute_limits: Top-level attribute_limits from the root config,
- used as a fallback when per-signal limits are not specified.
- """
- if config is None:
- return
- trace.set_tracer_provider(create_tracer_provider(config, resource, global_attribute_limits))
diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/file/__init__.py b/opentelemetry-configuration/src/opentelemetry/configuration/file/__init__.py
index abcda17e2b7..8c05d931f70 100644
--- a/opentelemetry-configuration/src/opentelemetry/configuration/file/__init__.py
+++ b/opentelemetry-configuration/src/opentelemetry/configuration/file/__init__.py
@@ -3,12 +3,12 @@
"""OpenTelemetry SDK File Configuration.
-This module provides support for configuring the OpenTelemetry SDK
-using declarative configuration files (YAML or JSON).
+This module provides support for parsing declarative configuration files
+(YAML or JSON) and creating SDK components from them.
Example:
- >>> from opentelemetry.configuration.file import load_config_file
- >>> config = load_config_file("otel-config.yaml")
+ >>> from opentelemetry.configuration.file import load_config
+ >>> config = load_config("otel-config.yaml")
>>> print(config.file_format)
'1.0'
"""
@@ -18,41 +18,33 @@
MissingDependencyError,
)
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,
- create_propagator,
-)
+from opentelemetry.configuration._propagator import create_propagator
from opentelemetry.configuration._resource import create_resource
-from opentelemetry.configuration._sdk import configure_sdk
from opentelemetry.configuration._tracer_provider import (
- configure_tracer_provider,
create_tracer_provider,
)
from opentelemetry.configuration.file._env_substitution import (
substitute_env_vars,
)
-from opentelemetry.configuration.file._loader import load_config_file
+from opentelemetry.configuration.file._loader import (
+ ConfigFileFormat,
+ load_config,
+)
__all__ = [
+ "ConfigFileFormat",
"ConfigurationError",
"MissingDependencyError",
- "configure_logger_provider",
- "configure_meter_provider",
- "configure_propagator",
- "configure_sdk",
- "configure_tracer_provider",
"create_logger_provider",
"create_meter_provider",
"create_propagator",
"create_resource",
"create_tracer_provider",
- "load_config_file",
+ "load_config",
"substitute_env_vars",
]
diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/file/_loader.py b/opentelemetry-configuration/src/opentelemetry/configuration/file/_loader.py
index f3a29aa7986..cef1c4af20f 100644
--- a/opentelemetry-configuration/src/opentelemetry/configuration/file/_loader.py
+++ b/opentelemetry-configuration/src/opentelemetry/configuration/file/_loader.py
@@ -9,7 +9,7 @@
import os
import re
from pathlib import Path
-from typing import Any
+from typing import Any, Literal, TextIO, get_args
from opentelemetry.configuration._conversion import _dict_to_dataclass
from opentelemetry.configuration._exceptions import (
@@ -49,6 +49,16 @@
_SUPPORTED_SCHEMA_MAJOR = 1
_SUPPORTED_SCHEMA_MINOR = 1
+ConfigFileFormat = Literal["yaml", "json"]
+
+_FILE_FORMATS: tuple[str, ...] = get_args(ConfigFileFormat)
+
+_SUFFIX_TO_FILE_FORMAT: dict[str, ConfigFileFormat] = {
+ ".yaml": "yaml",
+ ".yml": "yaml",
+ ".json": "json",
+}
+
_schema_cache: list[dict] = []
@@ -182,7 +192,7 @@ def _substitute_env_in_json_value(value: Any) -> Any:
return value
-def _parse_config_content(content: str, suffix: str, file_path: str | os.PathLike[str]) -> Any:
+def _parse_config_content(content: str, file_format: ConfigFileFormat, source_name: str) -> Any:
"""Parse configuration text and substitute environment variables.
Parsing happens first, so ``${VAR}`` references in comments and mapping
@@ -195,7 +205,7 @@ def _parse_config_content(content: str, suffix: str, file_path: str | os.PathLik
a required environment variable fails.
"""
try:
- if suffix == ".json":
+ if file_format == "json":
return _substitute_env_in_json_value(json.loads(content))
yaml_loader = yaml.SafeLoader(content)
try:
@@ -207,34 +217,22 @@ def _parse_config_content(content: str, suffix: str, file_path: str | os.PathLik
finally:
yaml_loader.dispose()
except yaml.YAMLError as exc:
- _logger.exception("Failed to parse YAML from %s", file_path)
+ _logger.exception("Failed to parse YAML from %s", source_name)
raise ConfigurationError(f"Failed to parse YAML: {exc}") from exc
except json.JSONDecodeError as exc:
- _logger.exception("Failed to parse JSON from %s", file_path)
+ _logger.exception("Failed to parse JSON from %s", source_name)
raise ConfigurationError(f"Failed to parse JSON: {exc}") from exc
-def load_config_file(
+def _read_config_path(
file_path: str | os.PathLike[str],
-) -> OpenTelemetryConfiguration:
- """Load and parse an OpenTelemetry configuration file.
-
- Supports YAML and JSON formats. Environment variable substitution is
- performed after parsing, on configuration values only, so ``${VAR}``
- references in comments or mapping keys are left untouched.
-
- Args:
- file_path: Path to the configuration file (.yaml, .yml, or .json).
-
- Returns:
- Parsed OpenTelemetryConfiguration object.
+ file_format: ConfigFileFormat | None,
+) -> tuple[str, ConfigFileFormat]:
+ """Read a configuration file, inferring its format from the extension.
Raises:
- ConfigurationError: If file cannot be read, parsed, or validated.
-
- Examples:
- >>> config = load_config_file("otel-config.yaml")
- >>> print(config.tracer_provider)
+ ConfigurationError: If the file is missing, unreadable, or has an
+ unsupported extension while ``file_format`` is not given.
"""
path = Path(file_path)
@@ -246,25 +244,67 @@ def load_config_file(
_logger.error("Configuration path is not a file: %s", file_path)
raise ConfigurationError(f"Configuration path is not a file: {file_path}")
+ if file_format is None:
+ suffix = path.suffix.lower()
+ if suffix not in _SUFFIX_TO_FILE_FORMAT:
+ _logger.error("Unsupported file format: %s", suffix)
+ raise ConfigurationError(f"Unsupported file format: {suffix}. Use .yaml, .yml, or .json")
+ file_format = _SUFFIX_TO_FILE_FORMAT[suffix]
+
try:
with open(path, encoding="utf-8") as config_file:
- content = config_file.read()
+ return config_file.read(), file_format
except OSError as exc:
_logger.exception("Failed to read configuration file: %s", file_path)
raise ConfigurationError(f"Failed to read configuration file: {file_path}") from exc
- # Parse the file, then substitute env vars in configuration values only.
- # Parsing first means comments and mapping keys are never substitution
- # candidates, and node types are still resolved after substitution.
- suffix = path.suffix.lower()
- if suffix not in (".yaml", ".yml", ".json"):
- _logger.error("Unsupported file format: %s", suffix)
- raise ConfigurationError(f"Unsupported file format: {suffix}. Use .yaml, .yml, or .json")
- data = _parse_config_content(content, suffix, file_path)
+def load_config(
+ source: str | os.PathLike[str] | TextIO,
+ file_format: ConfigFileFormat | None = None,
+) -> OpenTelemetryConfiguration:
+ """Parse and validate an OpenTelemetry configuration file.
+
+ Implements the configuration spec's Parse operation. Environment variable
+ substitution is performed after parsing, on configuration values only, so
+ ``${VAR}`` references in comments or mapping keys are left untouched. The
+ parsed content is validated against the configuration JSON schema.
+
+ Args:
+ source: Path to the configuration file, or a text stream of its
+ content.
+ file_format: ``"yaml"`` or ``"json"``. Inferred from the extension
+ (``.yaml``, ``.yml``, ``.json``) when ``source`` is a path;
+ required when ``source`` is a stream.
+
+ Returns:
+ Parsed OpenTelemetryConfiguration object.
+
+ Raises:
+ ConfigurationError: If the file cannot be read, parsed, or validated.
+ ValueError: If ``file_format`` is invalid, or missing for a stream.
+
+ Examples:
+ >>> config = load_config("otel-config.yaml")
+ >>> with open("otel-config.txt", encoding="utf-8") as stream:
+ ... config = load_config(stream, file_format="yaml")
+ """
+ if file_format is not None and file_format not in _FILE_FORMATS:
+ raise ValueError(f"Invalid file_format {file_format!r}: expected one of {list(_FILE_FORMATS)}")
+
+ if isinstance(source, (str, os.PathLike)):
+ source_name = os.fspath(source)
+ content, file_format = _read_config_path(source, file_format)
+ else:
+ if file_format is None:
+ raise ValueError("file_format is required when parsing configuration from a stream")
+ source_name = getattr(source, "name", "")
+ content = source.read()
+
+ data = _parse_config_content(content, file_format, source_name)
if data is None:
- _logger.error("Configuration file is empty: %s", file_path)
+ _logger.error("Configuration file is empty: %s", source_name)
raise ConfigurationError("Configuration file is empty")
if not isinstance(data, dict):
@@ -275,13 +315,13 @@ def load_config_file(
raise ConfigurationError(f"Configuration must be a mapping/object, got {type(data).__name__}")
_validate_schema(data)
- _validate_file_format(data)
+ _validate_file_format(data.get("file_format"))
# Convert to OpenTelemetryConfiguration model
try:
config = _dict_to_model(data)
except Exception as exc:
- _logger.exception("Failed to validate configuration from %s", file_path)
+ _logger.exception("Failed to validate configuration from %s", source_name)
raise ConfigurationError(f"Failed to validate configuration: {exc}") from exc
return config
@@ -309,7 +349,7 @@ def _validate_schema(data: dict) -> None:
raise ConfigurationError(f"Invalid configuration schema: {exc.message}") from exc
-def _validate_file_format(data: dict) -> None:
+def _validate_file_format(file_format: Any) -> None:
"""Validate the ``file_format`` version per the configuration spec.
The spec requires implementations to fail on an unsupported major version
@@ -321,9 +361,9 @@ def _validate_file_format(data: dict) -> None:
ConfigurationError: If ``file_format`` is malformed or its major
version is not supported.
"""
- file_format = data.get("file_format")
- # file_format is required and typed as a string by the schema, which is
- # validated before this runs; guard defensively regardless.
+ # file_format is required and typed as a string by the schema. Parsed
+ # files are schema-validated before this runs, but models built directly
+ # in code are not, so guard regardless.
if not isinstance(file_format, str):
raise ConfigurationError(f"Invalid file_format: expected a version string, got {file_format!r}")
diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/models.py b/opentelemetry-configuration/src/opentelemetry/configuration/models.py
index 5a3a98d268d..0b05cfa47c4 100644
--- a/opentelemetry-configuration/src/opentelemetry/configuration/models.py
+++ b/opentelemetry-configuration/src/opentelemetry/configuration/models.py
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: schema.json
-# timestamp: 2026-06-23T15:17:37+00:00
+# timestamp: 2026-10-22T01:45:51+00:00
from __future__ import annotations
@@ -8,7 +8,7 @@
from enum import Enum
from typing import Any, ClassVar, TypeAlias
-from opentelemetry.configuration._common import _additional_properties
+from opentelemetry.configuration._common import _additional_properties, _exactly_one_property
AlwaysOffSampler: TypeAlias = dict[str, Any] | None
@@ -348,6 +348,7 @@ class SeverityNumber(Enum):
fatal4 = "fatal4"
+@_exactly_one_property
@_additional_properties
@dataclass
class SpanExporter:
@@ -397,6 +398,7 @@ class ViewSelector:
meter_schema_url: str | None = None
+@_exactly_one_property
@dataclass
class Aggregation:
default: DefaultAggregation | None = None
@@ -492,6 +494,7 @@ class ExperimentalPrometheusMetricExporter:
translation_strategy: ExperimentalPrometheusTranslationStrategy | None = None
+@_exactly_one_property
@_additional_properties
@dataclass
class ExperimentalResourceDetector:
@@ -518,6 +521,7 @@ class ExperimentalTracerConfigurator:
tracers: list[ExperimentalTracerMatcherAndConfig] | None = None
+@_exactly_one_property
@_additional_properties
@dataclass
class IdGenerator:
@@ -525,6 +529,7 @@ class IdGenerator:
additional_properties: ClassVar[dict[str, Any]]
+@_exactly_one_property
@_additional_properties
@dataclass
class LogRecordExporter:
@@ -535,6 +540,7 @@ class LogRecordExporter:
additional_properties: ClassVar[dict[str, Any]]
+@_exactly_one_property
@_additional_properties
@dataclass
class MetricProducer:
@@ -542,6 +548,7 @@ class MetricProducer:
additional_properties: ClassVar[dict[str, Any]]
+@_exactly_one_property
@_additional_properties
@dataclass
class PullMetricExporter:
@@ -556,6 +563,7 @@ class PullMetricReader:
cardinality_limits: CardinalityLimits | None = None
+@_exactly_one_property
@_additional_properties
@dataclass
class PushMetricExporter:
@@ -576,6 +584,7 @@ class SimpleSpanProcessor:
exporter: SpanExporter
+@_exactly_one_property
@_additional_properties
@dataclass
class SpanProcessor:
@@ -584,6 +593,7 @@ class SpanProcessor:
additional_properties: ClassVar[dict[str, Any]]
+@_exactly_one_property
@_additional_properties
@dataclass
class TextMapPropagator:
@@ -652,6 +662,7 @@ class ExperimentalResourceDetection:
detectors: list[ExperimentalResourceDetector] | None = None
+@_exactly_one_property
@_additional_properties
@dataclass
class LogRecordProcessor:
@@ -698,6 +709,7 @@ class LoggerProvider:
logger_configurator_development: ExperimentalLoggerConfigurator | None = None
+@_exactly_one_property
@dataclass
class MetricReader:
periodic: PeriodicMetricReader | None = None
@@ -754,6 +766,7 @@ class ExperimentalComposableRuleBasedSamplerRule:
parent: list[ExperimentalSpanParent] | None = None
+@_exactly_one_property
@_additional_properties
@dataclass
class ExperimentalComposableSampler:
@@ -781,6 +794,7 @@ class ParentBasedSampler:
local_parent_not_sampled: Sampler | None = None
+@_exactly_one_property
@_additional_properties
@dataclass
class Sampler:
diff --git a/opentelemetry-configuration/tests/file/test_configurator_yaml.py b/opentelemetry-configuration/tests/file/test_configurator_yaml.py
index b9a64a65b32..a5586bc7169 100644
--- a/opentelemetry-configuration/tests/file/test_configurator_yaml.py
+++ b/opentelemetry-configuration/tests/file/test_configurator_yaml.py
@@ -16,7 +16,7 @@
from opentelemetry.configuration._tracer_provider import (
create_tracer_provider,
)
-from opentelemetry.configuration.file import load_config_file
+from opentelemetry.configuration.file import load_config
from opentelemetry.configuration.models import (
ExperimentalLoggerConfig,
ExperimentalLoggerConfigurator,
@@ -38,7 +38,7 @@ class TestConfiguratorYaml(unittest.TestCase):
@classmethod
def setUpClass(cls):
config_path = Path(__file__).parent / "data" / "configurator_config.yaml"
- cls.config = load_config_file(str(config_path))
+ cls.config = load_config(str(config_path))
def test_tracer_configurator_parsed_from_yaml(self):
self.assertEqual(
diff --git a/opentelemetry-configuration/tests/file/test_loader.py b/opentelemetry-configuration/tests/file/test_loader.py
index d52d8fc2dce..a2884bfb28f 100644
--- a/opentelemetry-configuration/tests/file/test_loader.py
+++ b/opentelemetry-configuration/tests/file/test_loader.py
@@ -1,6 +1,7 @@
# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0
+import io
import os
import tempfile
import unittest
@@ -14,7 +15,7 @@
)
from opentelemetry.configuration.file import (
ConfigurationError,
- load_config_file,
+ load_config,
)
from opentelemetry.configuration.file._loader import (
_SUPPORTED_SCHEMA_MAJOR,
@@ -56,7 +57,7 @@ def setUp(self):
def test_load_minimal_yaml(self):
"""Test loading minimal YAML configuration."""
config_path = self.test_data_dir / "minimal_config.yaml"
- config = load_config_file(str(config_path))
+ config = load_config(str(config_path))
self.assertIsInstance(config, OpenTelemetryConfiguration)
self.assertEqual(config.file_format, "1.0")
@@ -64,7 +65,7 @@ def test_load_minimal_yaml(self):
def test_load_minimal_json(self):
"""Test loading minimal JSON configuration."""
config_path = self.test_data_dir / "minimal_config.json"
- config = load_config_file(str(config_path))
+ config = load_config(str(config_path))
self.assertIsInstance(config, OpenTelemetryConfiguration)
self.assertEqual(config.file_format, "1.0")
@@ -74,21 +75,21 @@ def test_load_config_with_env_vars(self):
config_path = self.test_data_dir / "config_with_env_vars.yaml"
with patch.dict(os.environ, {"SERVICE_NAME": "test-service"}):
- config = load_config_file(str(config_path))
+ config = load_config(str(config_path))
self.assertIsInstance(config, OpenTelemetryConfiguration)
def test_file_not_found(self):
"""Test error when file doesn't exist."""
with self.assertRaises(ConfigurationError) as ctx:
- load_config_file("nonexistent_file.yaml")
+ load_config("nonexistent_file.yaml")
self.assertIn("not found", str(ctx.exception))
def test_directory_instead_of_file(self):
"""Test error when path is a directory."""
with self.assertRaises(ConfigurationError) as ctx:
- load_config_file(str(self.test_data_dir))
+ load_config(str(self.test_data_dir))
self.assertIn("not a file", str(ctx.exception))
@@ -97,7 +98,7 @@ def test_invalid_yaml_syntax(self):
config_path = self.test_data_dir / "invalid_yaml.yaml"
with self.assertRaises(ConfigurationError) as ctx:
- load_config_file(str(config_path))
+ load_config(str(config_path))
self.assertIn("yaml", str(ctx.exception).lower())
@@ -109,7 +110,7 @@ def test_invalid_file_extension(self):
try:
with self.assertRaises(ConfigurationError) as ctx:
- load_config_file(temp_path)
+ load_config(temp_path)
self.assertIn("Unsupported file format", str(ctx.exception))
finally:
@@ -122,7 +123,7 @@ def test_empty_file(self):
try:
with self.assertRaises(ConfigurationError) as ctx:
- load_config_file(temp_path)
+ load_config(temp_path)
self.assertIn("empty", str(ctx.exception).lower())
finally:
@@ -136,7 +137,7 @@ def test_non_dict_root(self):
try:
with self.assertRaises(ConfigurationError) as ctx:
- load_config_file(temp_path)
+ load_config(temp_path)
self.assertIn("mapping", str(ctx.exception).lower())
finally:
@@ -153,7 +154,7 @@ def test_unset_env_var_without_default_substitutes_empty(self):
config_path = self.test_data_dir / "config_with_env_vars.yaml"
with patch.dict(os.environ, {}, clear=True):
- config = load_config_file(str(config_path))
+ config = load_config(str(config_path))
attributes = {attribute.name: attribute.value for attribute in config.resource.attributes}
self.assertIsNone(attributes["service.name"])
@@ -166,7 +167,7 @@ def test_yml_extension(self):
temp_path = temp_file.name
try:
- config = load_config_file(temp_path)
+ config = load_config(temp_path)
self.assertEqual(config.file_format, "1.0")
finally:
os.unlink(temp_path)
@@ -179,7 +180,7 @@ def test_json_syntax_error(self):
try:
with self.assertRaises(ConfigurationError) as ctx:
- load_config_file(temp_path)
+ load_config(temp_path)
self.assertIn("json", str(ctx.exception).lower())
finally:
@@ -194,7 +195,7 @@ def test_schema_validation_wrong_type(self):
try:
with self.assertRaises(ConfigurationError) as ctx:
- load_config_file(temp_path)
+ load_config(temp_path)
self.assertIn("schema", str(ctx.exception).lower())
finally:
@@ -208,7 +209,7 @@ def test_schema_validation_missing_file_format(self):
try:
with self.assertRaises(ConfigurationError) as ctx:
- load_config_file(temp_path)
+ load_config(temp_path)
self.assertIn("schema", str(ctx.exception).lower())
finally:
@@ -222,7 +223,7 @@ def test_schema_validation_nested_path_in_error(self):
try:
with self.assertRaises(ConfigurationError) as ctx:
- load_config_file(temp_path)
+ load_config(temp_path)
error = str(ctx.exception)
self.assertIn("schema", error.lower())
@@ -239,13 +240,56 @@ def test_schema_validation_invalid_enum(self):
try:
with self.assertRaises(ConfigurationError) as ctx:
- load_config_file(temp_path)
+ load_config(temp_path)
self.assertIn("schema", str(ctx.exception).lower())
finally:
os.unlink(temp_path)
+class TestParseConfigSource(unittest.TestCase):
+ """load_config accepts paths and streams, with an optional file_format."""
+
+ def setUp(self):
+ self.test_data_dir = Path(__file__).parent / "data"
+
+ def test_load_path_like(self):
+ config = load_config(self.test_data_dir / "minimal_config.yaml")
+
+ self.assertEqual(config.file_format, "1.0")
+
+ def test_load_stream(self):
+ cases = [
+ ("yaml", 'file_format: "1.0"\n'),
+ ("json", '{"file_format": "1.0"}'),
+ ]
+ for file_format, content in cases:
+ with self.subTest(file_format=file_format):
+ config = load_config(io.StringIO(content), file_format=file_format)
+
+ self.assertIsInstance(config, OpenTelemetryConfiguration)
+ self.assertEqual(config.file_format, "1.0")
+
+ def test_stream_without_file_format_raises(self):
+ with self.assertRaises(ValueError) as ctx:
+ load_config(io.StringIO('file_format: "1.0"\n'))
+
+ self.assertIn("file_format is required", str(ctx.exception))
+
+ def test_invalid_file_format_argument_raises(self):
+ with self.assertRaises(ValueError):
+ load_config(io.StringIO('file_format: "1.0"\n'), file_format="toml")
+
+ def test_explicit_file_format_overrides_extension(self):
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as temp_file:
+ temp_file.write('{"file_format": "1.0"}')
+ self.addCleanup(os.unlink, temp_file.name)
+
+ config = load_config(temp_file.name, file_format="json")
+
+ self.assertEqual(config.file_format, "1.0")
+
+
class TestConfigLoaderEndToEnd(unittest.TestCase):
"""Smoke-test the full YAML -> typed config -> SDK object pipeline.
@@ -273,7 +317,7 @@ def _load(self, yaml_content: str | None = None) -> OpenTelemetryConfiguration:
fh.write(self._YAML if yaml_content is None else yaml_content)
path = fh.name
try:
- return load_config_file(path)
+ return load_config(path)
finally:
os.unlink(path)
@@ -330,9 +374,11 @@ def test_null_valued_required_field_node_survives_conversion(self):
# in the schema (so ``jaeger_remote_development:`` passes validation and
# is NOT rejected before conversion), yet its model has required
# fields. Coercing the present null into it would raise TypeError, so
- # it must be left as None instead. The rest of the config still loads.
- config = self._load(
- """
+ # it is left as None instead. That selects no sampler, so loading
+ # fails with a ConfigurationError rather than crashing.
+ with self.assertRaises(ConfigurationError) as ctx:
+ self._load(
+ """
file_format: '1.0'
tracer_provider:
processors:
@@ -342,13 +388,9 @@ def test_null_valued_required_field_node_survives_conversion(self):
sampler:
jaeger_remote_development:
"""
- )
+ )
- # Schema accepted the null, and conversion left the required-field
- # node unset rather than crashing.
- self.assertIsNone(config.tracer_provider.sampler.jaeger_remote_development)
- # A sibling nullable dict-typed node (console:) was still coerced.
- self.assertEqual(config.tracer_provider.processors[0].batch.exporter.console, {})
+ self.assertIn("Sampler requires exactly one of", str(ctx.exception))
class TestFileFormatValidation(unittest.TestCase):
@@ -362,7 +404,7 @@ def _load(file_format: str) -> OpenTelemetryConfiguration:
fh.write(f'file_format: "{file_format}"')
path = fh.name
try:
- return load_config_file(path)
+ return load_config(path)
finally:
os.unlink(path)
@@ -544,7 +586,7 @@ def _load_yaml(text: str) -> OpenTelemetryConfiguration:
fh.write(text)
path = fh.name
try:
- return load_config_file(path)
+ return load_config(path)
finally:
os.unlink(path)
diff --git a/opentelemetry-configuration/tests/test_common.py b/opentelemetry-configuration/tests/test_common.py
index eaf0aa942b7..d7f60def6d9 100644
--- a/opentelemetry-configuration/tests/test_common.py
+++ b/opentelemetry-configuration/tests/test_common.py
@@ -3,13 +3,15 @@
import inspect
import unittest
-from dataclasses import dataclass
+from dataclasses import dataclass, fields
from types import SimpleNamespace
from typing import Any, ClassVar
from unittest.mock import MagicMock, patch
+from opentelemetry.configuration import models
from opentelemetry.configuration._common import (
_additional_properties,
+ _exactly_one_property,
_map_compression,
_matches_glob,
_parse_headers,
@@ -18,6 +20,7 @@
load_entry_point,
)
from opentelemetry.configuration._exceptions import ConfigurationError
+from opentelemetry.configuration.file._loader import _get_schema
from opentelemetry.configuration.models import (
ExperimentalResourceDetector,
LogRecordExporter,
@@ -380,6 +383,93 @@ def test_push_metric_exporter(self):
self._assert_supports_additional_properties(PushMetricExporter)
+class TestExactlyOneProperty(unittest.TestCase):
+ def setUp(self):
+ @_exactly_one_property
+ @_additional_properties
+ @dataclass
+ class _Choice:
+ first: dict | None = None
+ second: dict | None = None
+ additional_properties: ClassVar[dict[str, Any]]
+
+ self.choice_cls = _Choice
+
+ def test_single_choice_accepted(self):
+ cases = [
+ {"first": {}},
+ {"second": {"key": "value"}},
+ {"my_plugin": {}},
+ {"my_plugin": None},
+ ]
+ for kwargs in cases:
+ with self.subTest(kwargs=kwargs):
+ self.choice_cls(**kwargs)
+
+ def test_zero_or_multiple_choices_rejected(self):
+ cases = [
+ {},
+ {"first": None},
+ {"first": {}, "second": {}},
+ {"first": {}, "my_plugin": {}},
+ {"plugin_a": {}, "plugin_b": None},
+ ]
+ for kwargs in cases:
+ with self.subTest(kwargs=kwargs):
+ with self.assertRaises(ValueError) as ctx:
+ self.choice_cls(**kwargs)
+ self.assertIn("_Choice requires exactly one of", str(ctx.exception))
+
+ def test_preserves_signature(self):
+ params = inspect.signature(self.choice_cls.__init__).parameters
+ self.assertIn("first", params)
+ self.assertIn("kwargs", params)
+
+
+class TestGeneratedModelsHaveExactlyOneProperty(unittest.TestCase):
+ """Guards the codegen wiring for ``minProperties: 1, maxProperties: 1``"""
+
+ @staticmethod
+ def _one_of_model_classes():
+ return [
+ getattr(models, definition.get("title", name))
+ for name, definition in _get_schema()["$defs"].items()
+ if definition.get("minProperties") == 1 and definition.get("maxProperties") == 1
+ ]
+
+ def test_schema_has_one_of_types(self):
+ self.assertIn(models.Sampler, self._one_of_model_classes())
+
+ def test_rejects_zero_choices(self):
+ for model_cls in self._one_of_model_classes():
+ with self.subTest(model=model_cls.__name__):
+ with self.assertRaises(ValueError):
+ model_cls()
+
+ def test_rejects_multiple_choices(self):
+ for model_cls in self._one_of_model_classes():
+ with self.subTest(model=model_cls.__name__):
+ field_names = [field.name for field in fields(model_cls)]
+ # pylint: disable=unexpected-keyword-arg
+ with self.assertRaises(ValueError):
+ if len(field_names) >= 2:
+ model_cls(**{field_names[0]: {}, field_names[1]: {}})
+ else:
+ model_cls(**{field_names[0]: {}, "_test_plugin_key": {}})
+
+ def test_accepts_single_choice(self):
+ for model_cls in self._one_of_model_classes():
+ with self.subTest(model=model_cls.__name__):
+ field_name = fields(model_cls)[0].name
+ self.assertEqual(getattr(model_cls(**{field_name: {}}), field_name), {})
+
+ def test_sampler_always_on_and_always_off_rejected(self):
+ with self.assertRaises(ValueError) as ctx:
+ Sampler(always_on={}, always_off={})
+ self.assertIn("always_off", str(ctx.exception))
+ self.assertIn("always_on", str(ctx.exception))
+
+
class TestResolveComponent(unittest.TestCase):
def setUp(self):
@_additional_properties
diff --git a/opentelemetry-configuration/tests/test_conversion.py b/opentelemetry-configuration/tests/test_conversion.py
index e12df181688..f21b4e9d162 100644
--- a/opentelemetry-configuration/tests/test_conversion.py
+++ b/opentelemetry-configuration/tests/test_conversion.py
@@ -90,8 +90,10 @@ def test_present_null_dataclass_with_required_field_stays_none(self):
# (ExperimentalJaegerRemoteSampler) has required fields, so it cannot
# be defaulted. A present null must stay None rather than raising a
# TypeError trying to instantiate it. Regression test for #5451.
- result = _dict_to_dataclass({"jaeger_remote_development": None}, SamplerConfig)
- self.assertIsNone(result.jaeger_remote_development)
+ # Leaving it None selects no sampler, which the exactly-one constraint
+ # rejects with a ValueError instead.
+ with self.assertRaises(ValueError):
+ _dict_to_dataclass({"jaeger_remote_development": None}, SamplerConfig)
def test_missing_optional_fields_default_to_none(self):
result = _dict_to_dataclass({}, _Outer)
diff --git a/opentelemetry-configuration/tests/test_logger_provider.py b/opentelemetry-configuration/tests/test_logger_provider.py
index 2446a8f3e4e..cc265e04213 100644
--- a/opentelemetry-configuration/tests/test_logger_provider.py
+++ b/opentelemetry-configuration/tests/test_logger_provider.py
@@ -10,7 +10,6 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
-from opentelemetry._logs import get_logger_provider
from opentelemetry.configuration._logger_provider import (
_DEFAULT_EXPORT_TIMEOUT_MILLIS,
_DEFAULT_MAX_EXPORT_BATCH_SIZE,
@@ -20,7 +19,6 @@
_create_log_record_exporter,
_create_log_record_processor,
_create_simple_log_record_processor,
- configure_logger_provider,
create_logger_provider,
)
from opentelemetry.configuration.file._loader import ConfigurationError
@@ -91,11 +89,6 @@ def test_config_with_no_processors(self):
provider = create_logger_provider(config)
self.assertIsInstance(provider, LoggerProvider)
- def test_configure_none_is_noop(self):
- original = get_logger_provider()
- configure_logger_provider(None)
- self.assertIs(get_logger_provider(), original)
-
class TestCreateLogRecordProcessors(unittest.TestCase):
@staticmethod
@@ -117,85 +110,84 @@ def _make_batch_config(
)
def test_batch_processor_default_schedule_delay(self):
- processor = _create_batch_log_record_processor(self._make_batch_config())
+ processor = _create_batch_log_record_processor(self._make_batch_config(), None)
self.assertEqual(
processor._batch_processor._schedule_delay_millis,
_DEFAULT_SCHEDULE_DELAY_MILLIS,
)
def test_batch_processor_default_export_timeout(self):
- processor = _create_batch_log_record_processor(self._make_batch_config())
+ processor = _create_batch_log_record_processor(self._make_batch_config(), None)
self.assertEqual(
processor._batch_processor._export_timeout_millis,
_DEFAULT_EXPORT_TIMEOUT_MILLIS,
)
def test_batch_processor_default_max_queue_size(self):
- processor = _create_batch_log_record_processor(self._make_batch_config())
+ processor = _create_batch_log_record_processor(self._make_batch_config(), None)
self.assertEqual(
processor._batch_processor._max_queue_size,
_DEFAULT_MAX_QUEUE_SIZE,
)
def test_batch_processor_default_max_export_batch_size(self):
- processor = _create_batch_log_record_processor(self._make_batch_config())
+ processor = _create_batch_log_record_processor(self._make_batch_config(), None)
self.assertEqual(
processor._batch_processor._max_export_batch_size,
_DEFAULT_MAX_EXPORT_BATCH_SIZE,
)
def test_batch_processor_explicit_schedule_delay(self):
- processor = _create_batch_log_record_processor(self._make_batch_config(schedule_delay=2000))
+ processor = _create_batch_log_record_processor(self._make_batch_config(schedule_delay=2000), None)
self.assertEqual(processor._batch_processor._schedule_delay_millis, 2000.0)
def test_batch_processor_zero_schedule_delay(self):
- processor = _create_batch_log_record_processor(self._make_batch_config(schedule_delay=0))
+ processor = _create_batch_log_record_processor(self._make_batch_config(schedule_delay=0), None)
self.assertEqual(processor._batch_processor._schedule_delay_millis, 0)
processor.shutdown()
def test_batch_processor_explicit_export_timeout(self):
- processor = _create_batch_log_record_processor(self._make_batch_config(export_timeout=5000))
+ processor = _create_batch_log_record_processor(self._make_batch_config(export_timeout=5000), None)
self.assertEqual(processor._batch_processor._export_timeout_millis, 5000.0)
def test_batch_processor_explicit_max_queue_size(self):
- processor = _create_batch_log_record_processor(self._make_batch_config(max_queue_size=512))
+ processor = _create_batch_log_record_processor(self._make_batch_config(max_queue_size=512), None)
self.assertEqual(processor._batch_processor._max_queue_size, 512)
def test_batch_processor_explicit_max_export_batch_size(self):
- processor = _create_batch_log_record_processor(self._make_batch_config(max_export_batch_size=128))
+ processor = _create_batch_log_record_processor(self._make_batch_config(max_export_batch_size=128), None)
self.assertEqual(processor._batch_processor._max_export_batch_size, 128)
def test_batch_processor_uses_console_exporter(self):
- processor = _create_batch_log_record_processor(self._make_batch_config())
+ processor = _create_batch_log_record_processor(self._make_batch_config(), None)
self.assertIsInstance(processor._batch_processor._exporter, ConsoleLogRecordExporter)
def test_simple_processor_uses_console_exporter(self):
config = SimpleLogRecordProcessorConfig(exporter=LogRecordExporterConfig(console={}))
- processor = _create_simple_log_record_processor(config)
+ processor = _create_simple_log_record_processor(config, None)
self.assertIsInstance(processor, SimpleLogRecordProcessor)
self.assertIsInstance(processor._exporter, ConsoleLogRecordExporter)
def test_batch_processor_dispatched_from_processor_config(self):
config = LogRecordProcessorConfig(batch=self._make_batch_config())
- processor = _create_log_record_processor(config)
+ processor = _create_log_record_processor(config, None)
self.assertIsInstance(processor, BatchLogRecordProcessor)
def test_simple_processor_dispatched_from_processor_config(self):
config = LogRecordProcessorConfig(
simple=SimpleLogRecordProcessorConfig(exporter=LogRecordExporterConfig(console={}))
)
- processor = _create_log_record_processor(config)
+ processor = _create_log_record_processor(config, None)
self.assertIsInstance(processor, SimpleLogRecordProcessor)
def test_no_processor_type_raises(self):
- config = LogRecordProcessorConfig()
- with self.assertRaises(ConfigurationError):
- _create_log_record_processor(config)
+ with self.assertRaises(ValueError):
+ LogRecordProcessorConfig()
def test_batch_processor_suppresses_env_var(self):
"""schedule_delay default must not read OTEL_BLRP_SCHEDULE_DELAY."""
with patch.dict("os.environ", {"OTEL_BLRP_SCHEDULE_DELAY": "9999"}):
- processor = _create_batch_log_record_processor(self._make_batch_config())
+ processor = _create_batch_log_record_processor(self._make_batch_config(), None)
self.assertEqual(
processor._batch_processor._schedule_delay_millis,
_DEFAULT_SCHEDULE_DELAY_MILLIS,
@@ -205,13 +197,12 @@ def test_batch_processor_suppresses_env_var(self):
class TestCreateLogRecordExporters(unittest.TestCase):
def test_console_exporter(self):
config = LogRecordExporterConfig(console={})
- exporter = _create_log_record_exporter(config)
+ exporter = _create_log_record_exporter(config, None)
self.assertIsInstance(exporter, ConsoleLogRecordExporter)
def test_no_exporter_type_raises(self):
- config = LogRecordExporterConfig()
- with self.assertRaises(ConfigurationError):
- _create_log_record_exporter(config)
+ with self.assertRaises(ValueError):
+ LogRecordExporterConfig()
def test_plugin_log_exporter_loaded_via_entry_point(self):
mock_exporter = MagicMock()
@@ -221,7 +212,7 @@ def test_plugin_log_exporter_loaded_via_entry_point(self):
return_value=[MagicMock(**{"load.return_value": mock_class})],
):
# pylint: disable=unexpected-keyword-arg
- result = _create_log_record_exporter(LogRecordExporterConfig(my_custom_exporter={}))
+ result = _create_log_record_exporter(LogRecordExporterConfig(my_custom_exporter={}), None)
self.assertIs(result, mock_exporter)
def test_unknown_log_exporter_raises_configuration_error(self):
@@ -233,7 +224,7 @@ def test_unknown_log_exporter_raises_configuration_error(self):
self.assertRaises(ConfigurationError),
):
# pylint: disable=unexpected-keyword-arg
- _create_log_record_exporter(LogRecordExporterConfig(no_such_exporter={}))
+ _create_log_record_exporter(LogRecordExporterConfig(no_such_exporter={}), None)
def test_otlp_http_missing_package_raises(self):
config = LogRecordExporterConfig(otlp_http=OtlpHttpExporterConfig(endpoint="http://localhost:4318"))
@@ -247,7 +238,7 @@ def test_otlp_http_missing_package_raises(self):
),
self.assertRaises(ConfigurationError),
):
- _create_log_record_exporter(config)
+ _create_log_record_exporter(config, None)
def test_otlp_http_json_missing_package_raises(self):
config = LogRecordExporterConfig(otlp_http=OtlpHttpExporterConfig(encoding=OtlpHttpEncoding.json))
@@ -261,7 +252,7 @@ def test_otlp_http_json_missing_package_raises(self):
),
self.assertRaises(ConfigurationError) as ctx,
):
- _create_log_record_exporter(config)
+ _create_log_record_exporter(config, None)
self.assertIn("opentelemetry-exporter-otlp-json-http", str(ctx.exception))
def test_otlp_grpc_missing_package_raises(self):
@@ -276,7 +267,7 @@ def test_otlp_grpc_missing_package_raises(self):
),
self.assertRaises(ConfigurationError),
):
- _create_log_record_exporter(config)
+ _create_log_record_exporter(config, None)
def test_otlp_file_development_missing_package_raises(self):
config = LogRecordExporterConfig(otlp_file_development=ExperimentalOtlpFileExporterConfig())
@@ -289,7 +280,7 @@ def test_otlp_file_development_missing_package_raises(self):
),
self.assertRaises(ConfigurationError) as ctx,
):
- _create_log_record_exporter(config)
+ _create_log_record_exporter(config, None)
self.assertIn("opentelemetry-exporter-otlp-json-file", str(ctx.exception))
def test_otlp_file_development_default_stdout(self):
@@ -304,7 +295,7 @@ def test_otlp_file_development_default_stdout(self):
},
):
config = LogRecordExporterConfig(otlp_file_development=ExperimentalOtlpFileExporterConfig())
- _create_log_record_exporter(config)
+ _create_log_record_exporter(config, None)
mock_exporter_cls.assert_called_once()
self.assertEqual(mock_exporter_cls.call_args.args, ())
@@ -324,7 +315,7 @@ def test_otlp_file_development_file_uri(self):
config = LogRecordExporterConfig(
otlp_file_development=ExperimentalOtlpFileExporterConfig(output_stream="file:///tmp/logs.jsonl")
)
- _create_log_record_exporter(config)
+ _create_log_record_exporter(config, None)
mock_exporter_cls.assert_called_once()
self.assertEqual(mock_exporter_cls.call_args.args, ("/tmp/logs.jsonl",))
@@ -344,7 +335,7 @@ def test_otlp_file_development_unsupported_output_stream_raises(self):
otlp_file_development=ExperimentalOtlpFileExporterConfig(output_stream="http://example")
)
with self.assertRaises(ConfigurationError) as ctx:
- _create_log_record_exporter(config)
+ _create_log_record_exporter(config, None)
self.assertIn("output_stream", str(ctx.exception))
mock_exporter_cls.assert_not_called()
@@ -372,7 +363,7 @@ def test_otlp_http_exporter_endpoint(self):
encoding=OtlpHttpEncoding.protobuf,
)
)
- _create_log_record_exporter(config)
+ _create_log_record_exporter(config, None)
mock_exporter_cls.assert_called_once()
call_kwargs = mock_exporter_cls.call_args.kwargs
@@ -404,7 +395,7 @@ def test_otlp_http_json_exporter_created_with_config(self):
encoding=OtlpHttpEncoding.json,
)
)
- _create_log_record_exporter(config)
+ _create_log_record_exporter(config, None)
mock_exporter_cls.assert_called_once_with(
endpoint="http://collector:4318/v1/logs",
@@ -431,7 +422,7 @@ def test_otlp_http_exporter_headers(self):
config = LogRecordExporterConfig(
otlp_http=OtlpHttpExporterConfig(headers=[NameStringValuePair(name="x-api-key", value="secret")])
)
- _create_log_record_exporter(config)
+ _create_log_record_exporter(config, None)
call_kwargs = mock_exporter_cls.call_args.kwargs
self.assertEqual(call_kwargs["headers"], {"x-api-key": "secret"})
@@ -453,7 +444,7 @@ def test_otlp_http_exporter_deflate_compression(self):
},
):
config = LogRecordExporterConfig(otlp_http=OtlpHttpExporterConfig(compression="deflate"))
- _create_log_record_exporter(config)
+ _create_log_record_exporter(config, None)
call_kwargs = mock_exporter_cls.call_args.kwargs
self.assertEqual(call_kwargs["compression"], "deflate")
@@ -478,7 +469,7 @@ def test_otlp_grpc_exporter_endpoint(self):
timeout=10000,
)
)
- _create_log_record_exporter(config)
+ _create_log_record_exporter(config, None)
mock_exporter_cls.assert_called_once()
call_kwargs = mock_exporter_cls.call_args.kwargs
diff --git a/opentelemetry-configuration/tests/test_meter_provider.py b/opentelemetry-configuration/tests/test_meter_provider.py
index 567cb523307..3626e39aac0 100644
--- a/opentelemetry-configuration/tests/test_meter_provider.py
+++ b/opentelemetry-configuration/tests/test_meter_provider.py
@@ -12,7 +12,6 @@
from opentelemetry.configuration._conversion import _dict_to_dataclass
from opentelemetry.configuration._meter_provider import (
- configure_meter_provider,
create_meter_provider,
)
from opentelemetry.configuration.file._loader import ConfigurationError
@@ -146,20 +145,6 @@ def test_none_config_does_not_read_interval_env_var(self):
self.assertIsInstance(reader, PeriodicExportingMetricReader)
self.assertEqual(reader._export_interval_millis, 60000.0)
- def test_configure_none_does_not_set_global(self):
- original = __import__("opentelemetry.metrics", fromlist=["get_meter_provider"]).get_meter_provider()
- configure_meter_provider(None)
- after = __import__("opentelemetry.metrics", fromlist=["get_meter_provider"]).get_meter_provider()
- self.assertIs(original, after)
-
- def test_configure_with_config_sets_global(self):
- config = MeterProviderConfig(readers=[])
- with patch("opentelemetry.configuration._meter_provider.metrics.set_meter_provider") as mock_set:
- configure_meter_provider(config)
- mock_set.assert_called_once()
- arg = mock_set.call_args[0][0]
- self.assertIsInstance(arg, MeterProvider)
-
class TestCreateMetricReaders(unittest.TestCase):
@staticmethod
@@ -183,6 +168,43 @@ def test_console_exporter(self):
self.assertIsInstance(reader, PeriodicExportingMetricReader)
self.assertIsInstance(reader._exporter, ConsoleMetricExporter)
+ def test_readers_are_registered_with_provider(self):
+ config = self._make_periodic_config(PushMetricExporterConfig(console=ConsoleMetricExporterConfig()))
+ provider = create_meter_provider(config)
+ reader = provider._metric_readers[0]
+ with patch.object(reader, "force_flush") as mock_force_flush:
+ provider.force_flush()
+ mock_force_flush.assert_called_once()
+
+ def test_failed_reader_shuts_down_created_readers(self):
+ config = MeterProviderConfig(
+ readers=[
+ MetricReaderConfig(
+ periodic=PeriodicMetricReaderConfig(
+ exporter=PushMetricExporterConfig(console=ConsoleMetricExporterConfig())
+ )
+ ),
+ MetricReaderConfig(
+ periodic=PeriodicMetricReaderConfig(
+ exporter=PushMetricExporterConfig(otlp_http=OtlpHttpMetricExporterConfig())
+ )
+ ),
+ ]
+ )
+ with (
+ patch.dict(
+ sys.modules,
+ {
+ "opentelemetry.exporter.otlp.proto.http.metric_exporter": None,
+ "opentelemetry.exporter.otlp.proto.http": None,
+ },
+ ),
+ patch.object(PeriodicExportingMetricReader, "shutdown") as mock_shutdown,
+ self.assertRaises(ConfigurationError),
+ ):
+ create_meter_provider(config)
+ mock_shutdown.assert_called_once()
+
def test_null_valued_console_exporter_from_parsed_config(self):
# Regression test for #5451: a dataclass-typed exporter written as
# ``console:`` (present, null) must behave like ``console: {}`` rather
@@ -268,9 +290,10 @@ def test_otlp_http_created_with_endpoint(self):
)
)
)
- create_meter_provider(config)
+ provider = create_meter_provider(config)
_, kwargs = mock_exporter_cls.call_args
+ self.assertIs(kwargs["meter_provider"], provider)
self.assertEqual(kwargs["endpoint"], "http://localhost:4318")
self.assertIsNone(kwargs["headers"])
self.assertIsNone(kwargs["timeout"])
@@ -370,6 +393,27 @@ def test_otlp_grpc_missing_package_raises(self):
create_meter_provider(config)
self.assertIn("otlp-proto-grpc", str(ctx.exception))
+ def test_otlp_grpc_created_with_meter_provider(self):
+ mock_exporter_cls = MagicMock()
+ mock_module = MagicMock()
+ mock_module.OTLPMetricExporter = mock_exporter_cls
+
+ with patch.dict(
+ sys.modules,
+ {
+ "opentelemetry.exporter.otlp.proto.grpc.metric_exporter": mock_module,
+ "grpc": MagicMock(),
+ },
+ ):
+ config = self._make_periodic_config(
+ PushMetricExporterConfig(otlp_grpc=OtlpGrpcMetricExporterConfig(endpoint="http://localhost:4317"))
+ )
+ provider = create_meter_provider(config)
+
+ _, kwargs = mock_exporter_cls.call_args
+ self.assertIs(kwargs["meter_provider"], provider)
+ self.assertEqual(kwargs["endpoint"], "http://localhost:4317")
+
def test_otlp_file_development_missing_package_raises(self):
config = self._make_periodic_config(
PushMetricExporterConfig(otlp_file_development=ExperimentalOtlpFileMetricExporterConfig())
@@ -544,11 +588,8 @@ def test_pull_prometheus_missing_package_raises(self):
create_meter_provider(config)
def test_pull_no_exporter_raises(self):
- config = MeterProviderConfig(
- readers=[MetricReaderConfig(pull=PullMetricReaderConfig(exporter=PullMetricExporterConfig()))]
- )
- with self.assertRaises(ConfigurationError):
- create_meter_provider(config)
+ with self.assertRaises(ValueError):
+ PullMetricExporterConfig()
def test_pull_plugin_loads_via_entry_point(self):
mock_reader = MagicMock()
@@ -651,14 +692,12 @@ def _make_periodic_config(exporter_config):
)
def test_no_reader_type_raises(self):
- config = MeterProviderConfig(readers=[MetricReaderConfig()])
- with self.assertRaises(ConfigurationError):
- create_meter_provider(config)
+ with self.assertRaises(ValueError):
+ MetricReaderConfig()
def test_no_exporter_type_raises(self):
- config = self._make_periodic_config(PushMetricExporterConfig())
- with self.assertRaises(ConfigurationError):
- create_meter_provider(config)
+ with self.assertRaises(ValueError):
+ PushMetricExporterConfig()
def test_plugin_metric_exporter_loaded_via_entry_point(self):
mock_exporter = MagicMock()
diff --git a/opentelemetry-configuration/tests/test_propagator.py b/opentelemetry-configuration/tests/test_propagator.py
index 9ed167433c0..a5a41acd9f3 100644
--- a/opentelemetry-configuration/tests/test_propagator.py
+++ b/opentelemetry-configuration/tests/test_propagator.py
@@ -11,7 +11,6 @@
from opentelemetry.baggage.propagation import W3CBaggagePropagator
from opentelemetry.configuration._exceptions import ConfigurationError
from opentelemetry.configuration._propagator import (
- configure_propagator,
create_propagator,
)
from opentelemetry.configuration.models import (
@@ -250,32 +249,11 @@ def test_unknown_composite_propagator_raises(self):
create_propagator(config)
-class TestConfigurePropagator(unittest.TestCase):
- def test_configure_propagator_calls_set_global_textmap(self):
- with patch("opentelemetry.configuration._propagator.set_global_textmap") as mock_set:
- configure_propagator(None)
- mock_set.assert_called_once()
- arg = mock_set.call_args[0][0]
- self.assertIsInstance(arg, CompositePropagator)
-
- def test_configure_propagator_with_config(self):
- config = PropagatorConfig(composite=[TextMapPropagatorConfig(tracecontext={})])
- with patch("opentelemetry.configuration._propagator.set_global_textmap") as mock_set:
- configure_propagator(config)
- mock_set.assert_called_once()
- propagator = mock_set.call_args[0][0]
- self.assertIsInstance(propagator, CompositePropagator)
- self.assertEqual(len(propagator._propagators), 1) # type: ignore[attr-defined]
-
+class TestCreatePropagatorIgnoresEnv(unittest.TestCase):
@patch.dict(environ, {OTEL_PROPAGATORS: "baggage"})
def test_otel_propagators_env_var_ignored(self):
- """OTEL_PROPAGATORS env var must not influence configure_propagator output."""
+ """OTEL_PROPAGATORS env var must not influence create_propagator output."""
config = PropagatorConfig(composite=[TextMapPropagatorConfig(tracecontext={})])
- with patch("opentelemetry.configuration._propagator.set_global_textmap") as mock_set:
- configure_propagator(config)
- propagator = mock_set.call_args[0][0]
- self.assertEqual(len(propagator._propagators), 1) # type: ignore[attr-defined]
- self.assertIsInstance(
- propagator._propagators[0],
- TraceContextTextMapPropagator, # type: ignore[attr-defined]
- )
+ propagator = create_propagator(config)
+ self.assertEqual(len(propagator._propagators), 1)
+ self.assertIsInstance(propagator._propagators[0], TraceContextTextMapPropagator)
diff --git a/opentelemetry-configuration/tests/test_sdk.py b/opentelemetry-configuration/tests/test_sdk.py
index 78e5cc7cb8b..7ee926cbf0c 100644
--- a/opentelemetry-configuration/tests/test_sdk.py
+++ b/opentelemetry-configuration/tests/test_sdk.py
@@ -1,14 +1,19 @@
# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0
-# Tests access private members of SDK classes to assert correct configuration.
-# pylint: disable=protected-access
-
+import io
import logging
import unittest
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
-from opentelemetry.configuration._sdk import configure_sdk
+from opentelemetry._logs import NoOpLoggerProvider
+from opentelemetry.configuration import (
+ ConfigurationError,
+ SdkComponents,
+ configure_sdk,
+ create_sdk,
+ load_config,
+)
from opentelemetry.configuration.models import (
OpenTelemetryConfiguration,
SeverityNumber,
@@ -16,9 +21,6 @@
from opentelemetry.configuration.models import (
Propagator as PropagatorConfig,
)
-from opentelemetry.configuration.models import (
- Resource as ResourceConfig,
-)
from opentelemetry.configuration.models import (
SimpleSpanProcessor as SimpleSpanProcessorConfig,
)
@@ -28,191 +30,329 @@
from opentelemetry.configuration.models import (
SpanProcessor as SpanProcessorConfig,
)
+from opentelemetry.configuration.models import (
+ TextMapPropagator as TextMapPropagatorConfig,
+)
from opentelemetry.configuration.models import (
TracerProvider as TracerProviderConfig,
)
+from opentelemetry.metrics import NoOpMeterProvider
+from opentelemetry.propagators.composite import CompositePropagator
+from opentelemetry.sdk.metrics import MeterProvider as SdkMeterProvider
+from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider
-
-_MIN_CONFIG_KWARGS = {"file_format": "1.0"}
+from opentelemetry.trace import NoOpTracerProvider
+from opentelemetry.trace.propagation.tracecontext import (
+ TraceContextTextMapPropagator,
+)
def _config(**kwargs) -> OpenTelemetryConfiguration:
- return OpenTelemetryConfiguration(**{**_MIN_CONFIG_KWARGS, **kwargs})
+ return OpenTelemetryConfiguration(**{"file_format": "1.0", **kwargs})
+
+
+def _tracer_provider_config() -> TracerProviderConfig:
+ return TracerProviderConfig(
+ processors=[SpanProcessorConfig(simple=SimpleSpanProcessorConfig(exporter=SpanExporterConfig(console={})))]
+ )
+
+
+def _tracecontext_propagator_config() -> PropagatorConfig:
+ return PropagatorConfig(composite=[TextMapPropagatorConfig(tracecontext={})])
+
+
+_TRACECONTEXT_FIELDS = TraceContextTextMapPropagator().fields
+
+
+class _PatchedGlobalsTestCase(unittest.TestCase):
+ """Replace the global setters used by ``_sdk`` with mocks."""
+
+ def setUp(self):
+ super().setUp()
+ self.set_tracer_provider = self._start_patch("opentelemetry.configuration._sdk.trace.set_tracer_provider")
+ self.set_meter_provider = self._start_patch("opentelemetry.configuration._sdk.metrics.set_meter_provider")
+ self.set_logger_provider = self._start_patch("opentelemetry.configuration._sdk.set_logger_provider")
+ self.set_global_textmap = self._start_patch("opentelemetry.configuration._sdk.set_global_textmap")
+ self.configure_instrumentation = self._start_patch("opentelemetry.configuration._sdk.configure_instrumentation")
+ def _start_patch(self, target: str) -> MagicMock:
+ patcher = patch(target)
+ self.addCleanup(patcher.stop)
+ return patcher.start()
+
+ def assert_no_globals_set(self):
+ self.set_tracer_provider.assert_not_called()
+ self.set_meter_provider.assert_not_called()
+ self.set_logger_provider.assert_not_called()
+ self.set_global_textmap.assert_not_called()
+
+
+class TestCreateSdk(_PatchedGlobalsTestCase):
+ def test_does_not_set_globals(self):
+ components = create_sdk(
+ _config(
+ tracer_provider=_tracer_provider_config(),
+ propagator=_tracecontext_propagator_config(),
+ )
+ )
+ self.addCleanup(components.shutdown)
+
+ self.assertIsInstance(components.tracer_provider, SdkTracerProvider)
+ self.assert_no_globals_set()
+ self.configure_instrumentation.assert_not_called()
+
+ def test_omitted_sections_return_noop_components(self):
+ components = create_sdk(_config())
+
+ self.assertIsInstance(components.tracer_provider, NoOpTracerProvider)
+ self.assertIsInstance(components.meter_provider, NoOpMeterProvider)
+ self.assertIsInstance(components.logger_provider, NoOpLoggerProvider)
+ self.assertIsInstance(components.propagator, CompositePropagator)
+ self.assertEqual(components.propagator.fields, set())
+ self.assertIsInstance(components.resource, Resource)
-class TestConfigureSdk(unittest.TestCase):
- @patch("opentelemetry.configuration._sdk.configure_propagator")
- @patch("opentelemetry.configuration._sdk.configure_logger_provider")
- @patch("opentelemetry.configuration._sdk.configure_meter_provider")
- @patch("opentelemetry.configuration._sdk.configure_tracer_provider")
- @patch("opentelemetry.configuration._sdk.create_resource")
# pylint: disable=no-self-use
- def test_calls_each_signal_with_resource(
- self,
- mock_create_resource,
- mock_tracer,
- mock_meter,
- mock_logger,
- mock_propagator,
- ):
- sentinel_resource = object()
- mock_create_resource.return_value = sentinel_resource
-
- resource_cfg = ResourceConfig()
- tracer_cfg = TracerProviderConfig(processors=[])
- propagator_cfg = PropagatorConfig()
- config = _config(
- resource=resource_cfg,
- tracer_provider=tracer_cfg,
- propagator=propagator_cfg,
+ def test_providers_share_resource(self):
+ tracer_provider_config = _tracer_provider_config()
+ with patch("opentelemetry.configuration._sdk.create_tracer_provider") as mock_create:
+ components = create_sdk(_config(tracer_provider=tracer_provider_config))
+
+ mock_create.assert_called_once_with(
+ tracer_provider_config,
+ components.resource,
+ None,
+ meter_provider=components.meter_provider,
)
- configure_sdk(config)
+ def test_meter_provider_threaded_to_other_providers(self):
+ created_meter_provider = MagicMock(spec=SdkMeterProvider)
+ with (
+ patch(
+ "opentelemetry.configuration._sdk.create_meter_provider",
+ return_value=created_meter_provider,
+ ),
+ patch("opentelemetry.configuration._sdk.create_tracer_provider") as mock_tracer,
+ patch("opentelemetry.configuration._sdk.create_logger_provider") as mock_logger,
+ ):
+ create_sdk(
+ _config(
+ tracer_provider=_tracer_provider_config(),
+ meter_provider=MagicMock(),
+ logger_provider=MagicMock(),
+ )
+ )
+
+ self.assertIs(mock_tracer.call_args.kwargs["meter_provider"], created_meter_provider)
+ self.assertIs(mock_logger.call_args.kwargs["meter_provider"], created_meter_provider)
+
+ def test_disabled_returns_noop_providers_with_configured_propagator(self):
+ with self.assertLogs("opentelemetry.configuration._sdk", level="WARNING"):
+ components = create_sdk(
+ _config(
+ disabled=True,
+ tracer_provider=_tracer_provider_config(),
+ propagator=_tracecontext_propagator_config(),
+ )
+ )
+
+ self.assertIsInstance(components.tracer_provider, NoOpTracerProvider)
+ self.assertIsInstance(components.meter_provider, NoOpMeterProvider)
+ self.assertIsInstance(components.logger_provider, NoOpLoggerProvider)
+ self.assertEqual(components.propagator.fields, _TRACECONTEXT_FIELDS)
+
+ def test_failure_shuts_down_created_providers(self):
+ created_meter_provider = MagicMock(spec=SdkMeterProvider)
+ created_tracer_provider = MagicMock(spec=SdkTracerProvider)
+ with (
+ patch(
+ "opentelemetry.configuration._sdk.create_meter_provider",
+ return_value=created_meter_provider,
+ ),
+ patch(
+ "opentelemetry.configuration._sdk.create_tracer_provider",
+ return_value=created_tracer_provider,
+ ),
+ patch(
+ "opentelemetry.configuration._sdk.create_logger_provider",
+ side_effect=ConfigurationError("boom"),
+ ),
+ ):
+ with self.assertRaises(ConfigurationError) as ctx:
+ create_sdk(
+ _config(
+ tracer_provider=_tracer_provider_config(),
+ meter_provider=MagicMock(),
+ logger_provider=MagicMock(),
+ )
+ )
- mock_create_resource.assert_called_once_with(resource_cfg)
- mock_tracer.assert_called_once_with(tracer_cfg, sentinel_resource, None)
- mock_meter.assert_called_once_with(None, sentinel_resource)
- mock_logger.assert_called_once_with(None, sentinel_resource, None)
- mock_propagator.assert_called_once_with(propagator_cfg)
+ self.assertEqual(str(ctx.exception), "boom")
+ created_meter_provider.shutdown.assert_called_once_with()
+ created_tracer_provider.shutdown.assert_called_once_with()
- @patch("opentelemetry.configuration._sdk.configure_propagator")
- @patch("opentelemetry.configuration._sdk.configure_logger_provider")
- @patch("opentelemetry.configuration._sdk.configure_meter_provider")
- @patch("opentelemetry.configuration._sdk.configure_tracer_provider")
- @patch("opentelemetry.configuration._sdk.create_resource")
+ def test_failure_during_cleanup_still_raises_original_error(self):
+ created_tracer_provider = MagicMock(spec=SdkTracerProvider)
+ created_tracer_provider.shutdown.side_effect = RuntimeError("shutdown failed")
+ with (
+ patch(
+ "opentelemetry.configuration._sdk.create_tracer_provider",
+ return_value=created_tracer_provider,
+ ),
+ patch(
+ "opentelemetry.configuration._sdk.create_logger_provider",
+ side_effect=ConfigurationError("boom"),
+ ),
+ ):
+ with self.assertLogs("opentelemetry.configuration._sdk", level="ERROR"):
+ with self.assertRaises(ConfigurationError):
+ create_sdk(
+ _config(
+ tracer_provider=_tracer_provider_config(),
+ logger_provider=MagicMock(),
+ )
+ )
+
+ def test_invalid_file_format_raises(self):
+ for file_format in ("2.0", "not-a-version", None):
+ with self.subTest(file_format=file_format):
+ with self.assertRaises(ConfigurationError):
+ create_sdk(OpenTelemetryConfiguration(file_format=file_format))
+
+
+class TestSdkComponentsShutdown(unittest.TestCase):
# pylint: disable=no-self-use
- def test_disabled_skips_everything(
- self,
- mock_create_resource,
- mock_tracer,
- mock_meter,
- mock_logger,
- mock_propagator,
- ):
- config = _config(
- disabled=True,
- tracer_provider=TracerProviderConfig(processors=[]),
+ def test_shuts_down_sdk_providers_and_skips_noop(self):
+ tracer_provider = MagicMock(spec=SdkTracerProvider)
+ components = SdkComponents(
+ tracer_provider=tracer_provider,
+ meter_provider=NoOpMeterProvider(),
+ logger_provider=NoOpLoggerProvider(),
+ propagator=CompositePropagator([]),
+ resource=Resource.get_empty(),
)
- configure_sdk(config)
-
- mock_create_resource.assert_not_called()
- mock_tracer.assert_not_called()
- mock_meter.assert_not_called()
- mock_logger.assert_not_called()
- mock_propagator.assert_not_called()
-
- @patch("opentelemetry.configuration._sdk.configure_propagator")
- @patch("opentelemetry.configuration._sdk.configure_logger_provider")
- @patch("opentelemetry.configuration._sdk.configure_meter_provider")
- @patch("opentelemetry.configuration._sdk.configure_tracer_provider")
- @patch("opentelemetry.configuration._sdk.create_resource")
- def test_absent_sections_pass_none(
- self,
- mock_create_resource,
- mock_tracer,
- mock_meter,
- mock_logger,
- mock_propagator,
- ):
+ components.shutdown()
+
+ tracer_provider.shutdown.assert_called_once_with()
+
+
+class TestConfigureSdk(_PatchedGlobalsTestCase):
+ def test_sets_all_globals(self):
+ components = configure_sdk(_config(tracer_provider=_tracer_provider_config()))
+ self.addCleanup(components.shutdown)
+
+ self.assertIsInstance(components.tracer_provider, SdkTracerProvider)
+ self.set_tracer_provider.assert_called_once_with(components.tracer_provider)
+ self.set_meter_provider.assert_called_once_with(components.meter_provider)
+ self.set_logger_provider.assert_called_once_with(components.logger_provider)
+ self.set_global_textmap.assert_called_once_with(components.propagator)
+
+ def test_omitted_sections_set_noop_globals(self):
configure_sdk(_config())
- # Each configure_* is called exactly once, with config=None.
- self.assertEqual(mock_tracer.call_args.args[0], None)
- self.assertEqual(mock_meter.call_args.args[0], None)
- self.assertEqual(mock_logger.call_args.args[0], None)
- self.assertEqual(mock_propagator.call_args.args[0], None)
+ self.assertIsInstance(self.set_tracer_provider.call_args.args[0], NoOpTracerProvider)
+ self.assertIsInstance(self.set_meter_provider.call_args.args[0], NoOpMeterProvider)
+ self.assertIsInstance(self.set_logger_provider.call_args.args[0], NoOpLoggerProvider)
+ self.assertEqual(self.set_global_textmap.call_args.args[0].fields, set())
+
+ def test_configures_from_parsed_file(self):
+ config = load_config(
+ io.StringIO(
+ 'file_format: "1.0"\n'
+ "tracer_provider:\n"
+ " processors:\n"
+ " - simple:\n"
+ " exporter:\n"
+ " console:\n"
+ ),
+ file_format="yaml",
+ )
+
+ components = configure_sdk(config)
+ self.addCleanup(components.shutdown)
+
+ self.assertIsInstance(components.tracer_provider, SdkTracerProvider)
+ self.set_tracer_provider.assert_called_once_with(components.tracer_provider)
+
+ def test_no_globals_set_on_create_failure(self):
+ with patch(
+ "opentelemetry.configuration._sdk.create_propagator",
+ side_effect=ConfigurationError("boom"),
+ ):
+ with self.assertRaises(ConfigurationError):
+ configure_sdk(_config(tracer_provider=_tracer_provider_config()))
+
+ self.assert_no_globals_set()
+ self.configure_instrumentation.assert_not_called()
+ def test_no_globals_set_on_invalid_file_format(self):
+ with self.assertRaises(ConfigurationError):
+ configure_sdk(OpenTelemetryConfiguration(file_format="2.0"))
+
+ self.assert_no_globals_set()
+
+ def test_disabled_sets_noop_globals_and_configured_propagator(self):
+ with self.assertLogs("opentelemetry.configuration._sdk", level="WARNING"):
+ configure_sdk(
+ _config(
+ disabled=True,
+ tracer_provider=_tracer_provider_config(),
+ propagator=_tracecontext_propagator_config(),
+ )
+ )
-class TestConfigureSdkLogLevel(unittest.TestCase):
+ self.assertIsInstance(self.set_tracer_provider.call_args.args[0], NoOpTracerProvider)
+ self.assertIsInstance(self.set_meter_provider.call_args.args[0], NoOpMeterProvider)
+ self.assertIsInstance(self.set_logger_provider.call_args.args[0], NoOpLoggerProvider)
+ self.assertEqual(self.set_global_textmap.call_args.args[0].fields, _TRACECONTEXT_FIELDS)
+ self.configure_instrumentation.assert_not_called()
+
+ def test_configures_instrumentation_when_enabled(self):
+ configure_sdk(_config())
+
+ self.configure_instrumentation.assert_called_once_with(None)
+
+
+class TestConfigureSdkLogLevel(_PatchedGlobalsTestCase):
def setUp(self):
+ super().setUp()
# Preserve whatever level was set before this test so we can
- # restore it in tearDown, keeping tests isolated from each other
+ # restore it afterwards, keeping tests isolated from each other
# and from the ambient logging configuration.
- self._original_level = logging.getLogger("opentelemetry").level
-
- def tearDown(self):
- logging.getLogger("opentelemetry").setLevel(self._original_level)
-
- @patch("opentelemetry.configuration._sdk.configure_propagator")
- @patch("opentelemetry.configuration._sdk.configure_logger_provider")
- @patch("opentelemetry.configuration._sdk.configure_meter_provider")
- @patch("opentelemetry.configuration._sdk.configure_tracer_provider")
- @patch("opentelemetry.configuration._sdk.create_resource")
- def test_sets_opentelemetry_logger_level(self, *_mocks):
- configure_sdk(_config(log_level=SeverityNumber.warn))
- self.assertEqual(logging.getLogger("opentelemetry").level, logging.WARNING)
+ original_level = logging.getLogger("opentelemetry").level
+ self.addCleanup(logging.getLogger("opentelemetry").setLevel, original_level)
+
+ def test_severity_number_variants_map_correctly(self):
+ cases = {
+ logging.DEBUG: ["trace", "trace2", "trace3", "trace4", "debug", "debug2", "debug3", "debug4"],
+ logging.INFO: ["info", "info2", "info3", "info4"],
+ logging.WARNING: ["warn", "warn2", "warn3", "warn4"],
+ logging.ERROR: ["error", "error2", "error3", "error4"],
+ logging.CRITICAL: ["fatal", "fatal2", "fatal3", "fatal4"],
+ }
+ for expected_level, severities in cases.items():
+ for severity in severities:
+ with self.subTest(severity=severity):
+ configure_sdk(_config(log_level=SeverityNumber(severity)))
+ self.assertEqual(logging.getLogger("opentelemetry").level, expected_level)
- @patch("opentelemetry.configuration._sdk.configure_propagator")
- @patch("opentelemetry.configuration._sdk.configure_logger_provider")
- @patch("opentelemetry.configuration._sdk.configure_meter_provider")
- @patch("opentelemetry.configuration._sdk.configure_tracer_provider")
- @patch("opentelemetry.configuration._sdk.create_resource")
- def test_absent_log_level_leaves_logger_unchanged(self, *_mocks):
+ def test_absent_log_level_leaves_logger_unchanged(self):
logging.getLogger("opentelemetry").setLevel(logging.ERROR)
configure_sdk(_config())
self.assertEqual(logging.getLogger("opentelemetry").level, logging.ERROR)
- @patch("opentelemetry.configuration._sdk.configure_propagator")
- @patch("opentelemetry.configuration._sdk.configure_logger_provider")
- @patch("opentelemetry.configuration._sdk.configure_meter_provider")
- @patch("opentelemetry.configuration._sdk.configure_tracer_provider")
- @patch("opentelemetry.configuration._sdk.create_resource")
- def test_severity_number_variants_map_correctly(self, *_mocks):
- cases = [
- (SeverityNumber.trace, logging.DEBUG),
- (SeverityNumber.trace2, logging.DEBUG),
- (SeverityNumber.trace3, logging.DEBUG),
- (SeverityNumber.trace4, logging.DEBUG),
- (SeverityNumber.debug, logging.DEBUG),
- (SeverityNumber.debug2, logging.DEBUG),
- (SeverityNumber.debug3, logging.DEBUG),
- (SeverityNumber.debug4, logging.DEBUG),
- (SeverityNumber.info, logging.INFO),
- (SeverityNumber.info2, logging.INFO),
- (SeverityNumber.info3, logging.INFO),
- (SeverityNumber.info4, logging.INFO),
- (SeverityNumber.warn, logging.WARNING),
- (SeverityNumber.warn2, logging.WARNING),
- (SeverityNumber.warn3, logging.WARNING),
- (SeverityNumber.warn4, logging.WARNING),
- (SeverityNumber.error, logging.ERROR),
- (SeverityNumber.error2, logging.ERROR),
- (SeverityNumber.error3, logging.ERROR),
- (SeverityNumber.error4, logging.ERROR),
- (SeverityNumber.fatal, logging.CRITICAL),
- (SeverityNumber.fatal2, logging.CRITICAL),
- (SeverityNumber.fatal3, logging.CRITICAL),
- (SeverityNumber.fatal4, logging.CRITICAL),
- ]
- for severity, expected_level in cases:
- with self.subTest(severity=severity):
- configure_sdk(_config(log_level=severity))
- self.assertEqual(
- logging.getLogger("opentelemetry").level,
- expected_level,
- )
+ def test_log_level_applied_when_disabled(self):
+ logging.getLogger("opentelemetry").setLevel(logging.WARNING)
+ with self.assertLogs("opentelemetry.configuration._sdk", level="WARNING"):
+ configure_sdk(_config(disabled=True, log_level=SeverityNumber.error))
+ self.assertEqual(logging.getLogger("opentelemetry").level, logging.ERROR)
- def test_log_level_not_applied_when_disabled(self):
+ def test_log_level_not_applied_on_failure(self):
logging.getLogger("opentelemetry").setLevel(logging.WARNING)
- configure_sdk(_config(disabled=True, log_level=SeverityNumber.error))
+ with patch(
+ "opentelemetry.configuration._sdk.create_propagator",
+ side_effect=ConfigurationError("boom"),
+ ):
+ with self.assertRaises(ConfigurationError):
+ configure_sdk(_config(log_level=SeverityNumber.error))
self.assertEqual(logging.getLogger("opentelemetry").level, logging.WARNING)
-
-
-class TestConfigureSdkIntegration(unittest.TestCase):
- """End-to-end: build a real OpenTelemetryConfiguration and apply it."""
-
- @patch("opentelemetry.configuration._tracer_provider.trace.set_tracer_provider")
- def test_applies_tracer_provider_globally(self, mock_set_tracer):
- config = _config(
- tracer_provider=TracerProviderConfig(
- processors=[
- SpanProcessorConfig(simple=SimpleSpanProcessorConfig(exporter=SpanExporterConfig(console={})))
- ]
- )
- )
-
- configure_sdk(config)
-
- mock_set_tracer.assert_called_once()
- self.assertIsInstance(mock_set_tracer.call_args[0][0], SdkTracerProvider)
diff --git a/opentelemetry-configuration/tests/test_tracer_provider.py b/opentelemetry-configuration/tests/test_tracer_provider.py
index 05b91a3e830..5ce02683c5e 100644
--- a/opentelemetry-configuration/tests/test_tracer_provider.py
+++ b/opentelemetry-configuration/tests/test_tracer_provider.py
@@ -14,7 +14,6 @@
from opentelemetry import trace as trace_api
from opentelemetry.configuration._conversion import _dict_to_dataclass
from opentelemetry.configuration._tracer_provider import (
- configure_tracer_provider,
create_tracer_provider,
)
from opentelemetry.configuration.file._loader import ConfigurationError
@@ -147,20 +146,6 @@ def test_none_config_does_not_read_span_limit_env_var(self):
provider = create_tracer_provider(None)
self.assertEqual(provider._span_limits.max_span_attributes, 128)
- def test_configure_none_does_not_set_global(self):
- original = __import__("opentelemetry.trace", fromlist=["get_tracer_provider"]).get_tracer_provider()
- configure_tracer_provider(None)
- after = __import__("opentelemetry.trace", fromlist=["get_tracer_provider"]).get_tracer_provider()
- self.assertIs(original, after)
-
- def test_configure_with_config_sets_global(self):
- config = TracerProviderConfig(processors=[])
- with patch("opentelemetry.configuration._tracer_provider.trace.set_tracer_provider") as mock_set:
- configure_tracer_provider(config)
- mock_set.assert_called_once()
- arg = mock_set.call_args[0][0]
- self.assertIsInstance(arg, TracerProvider)
-
def test_processors_added_in_order(self):
mock_proc_a = MagicMock()
mock_proc_b = MagicMock()
@@ -240,9 +225,13 @@ def test_parent_based_with_delegate_samplers(self):
self.assertIs(sampler._local_parent_sampled, ALWAYS_ON)
self.assertIs(sampler._local_parent_not_sampled, ALWAYS_OFF)
- def test_no_sampler_raises_configuration_error(self):
- with self.assertRaises(ConfigurationError):
- self._make_provider(SamplerConfig())
+ def test_no_sampler_raises_value_error(self):
+ with self.assertRaises(ValueError):
+ SamplerConfig()
+
+ def test_multiple_samplers_raise_value_error(self):
+ with self.assertRaises(ValueError):
+ SamplerConfig(always_on={}, always_off={})
def test_null_valued_always_on_from_parsed_config(self):
# Regression test for #5451: a leaf sampler written as ``always_on:``
@@ -636,13 +625,15 @@ def test_otlp_http_created_with_endpoint(self):
)
)
)
- create_tracer_provider(config)
+ meter_provider = MagicMock()
+ create_tracer_provider(config, meter_provider=meter_provider)
mock_exporter_cls.assert_called_once_with(
endpoint="http://localhost:4318",
headers=None,
timeout=None,
compression=None,
+ meter_provider=meter_provider,
)
def test_otlp_http_json_missing_package_raises(self):
@@ -831,14 +822,12 @@ def test_otlp_grpc_missing_package_raises(self):
self.assertIn("otlp-proto-grpc", str(ctx.exception))
def test_no_processor_type_raises(self):
- config = TracerProviderConfig(processors=[SpanProcessorConfig()])
- with self.assertRaises(ConfigurationError):
- create_tracer_provider(config)
+ with self.assertRaises(ValueError):
+ SpanProcessorConfig()
def test_no_exporter_type_raises(self):
- config = self._make_batch_config(SpanExporterConfig())
- with self.assertRaises(ConfigurationError):
- create_tracer_provider(config)
+ with self.assertRaises(ValueError):
+ SpanExporterConfig()
def test_plugin_span_exporter_loaded_via_entry_point(self):
mock_exporter = MagicMock()
@@ -954,10 +943,10 @@ def test_unknown_id_generator_raises_configuration_error(self):
# pylint: disable=unexpected-keyword-arg
self._make_provider(IdGeneratorConfig(no_such_generator={}))
- def test_empty_id_generator_raises_configuration_error(self):
- """Empty IdGenerator config (no type specified) raises ConfigurationError."""
- with self.assertRaises(ConfigurationError):
- self._make_provider(IdGeneratorConfig())
+ def test_empty_id_generator_raises_value_error(self):
+ """Empty IdGenerator config (no type specified) raises ValueError."""
+ with self.assertRaises(ValueError):
+ IdGeneratorConfig()
# Configurator tests access the SDK TracerProvider private
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py
index 2516e14146a..c87b85bea2a 100644
--- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py
@@ -656,7 +656,7 @@ def _configure(self, **kwargs):
# pylint: disable=import-outside-toplevel,no-name-in-module
from opentelemetry.configuration import ( # noqa: PLC0415
configure_sdk,
- load_config_file,
+ load_config,
)
except ImportError as exc:
raise RuntimeError(
@@ -671,6 +671,6 @@ def _configure(self, **kwargs):
OTEL_CONFIG_FILE,
sorted(kwargs),
)
- configure_sdk(load_config_file(config_file))
+ configure_sdk(load_config(config_file))
return
_initialize_components(**kwargs)
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py
index 2a43dc299c7..f0226f26947 100644
--- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py
@@ -442,6 +442,7 @@ def __init__(
):
self._lock = Lock()
self._meter_lock = Lock()
+ self._metric_readers_lock = Lock()
self._atexit_handler = None
if resource is None:
resource = Resource.create({})
@@ -493,6 +494,7 @@ def _after_in_child() -> None:
def _handle_fork(self) -> None:
self._lock = Lock()
self._meter_lock = Lock()
+ self._metric_readers_lock = Lock()
type(self)._all_metric_readers_lock = Lock()
self._update_resource(_get_process_dependent_resource())
@@ -640,9 +642,16 @@ def add_metric_reader(self, metric_reader: "opentelemetry.sdk.metrics.export.Met
)
return
self._measurement_consumer.add_metric_reader(metric_reader)
- # pylint: disable-next=protected-access
- metric_reader._set_collect_callback(self._measurement_consumer.collect)
self._all_metric_readers.add(metric_reader)
+ # Replaced rather than mutated so force_flush and shutdown can
+ # iterate it without holding the lock.
+ with self._metric_readers_lock:
+ self._metric_readers = (*self._metric_readers, metric_reader)
+
+ # pylint: disable-next=protected-access
+ metric_reader._set_collect_callback(self._measurement_consumer.collect)
+ # pylint: disable-next=protected-access
+ metric_reader._set_meter_provider(self)
def remove_metric_reader(
self,
@@ -653,7 +662,10 @@ def remove_metric_reader(
_logger.warning("MetricReader '%s' has not been registered!", metric_reader)
return
self._measurement_consumer.remove_metric_reader(metric_reader)
- # pylint: disable-next=protected-access
- metric_reader._set_collect_callback(None)
- metric_reader.shutdown()
self._all_metric_readers.remove(metric_reader)
+ with self._metric_readers_lock:
+ self._metric_readers = tuple(reader for reader in self._metric_readers if reader is not metric_reader)
+
+ # pylint: disable-next=protected-access
+ metric_reader._set_collect_callback(None)
+ metric_reader.shutdown()
diff --git a/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py b/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py
index bddde2a1bf2..f17e719b526 100644
--- a/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py
+++ b/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py
@@ -23,12 +23,12 @@ class _FakeConfigurationModule(types.ModuleType):
"""
configure_sdk: MagicMock
- load_config_file: MagicMock
+ load_config: MagicMock
def __init__(self) -> None:
super().__init__("opentelemetry.configuration")
self.configure_sdk = MagicMock()
- self.load_config_file = MagicMock()
+ self.load_config = MagicMock()
class TestConfiguratorFileRouting(unittest.TestCase):
@@ -49,12 +49,12 @@ def test_env_var_unset_runs_env_var_path(self, mock_init_components):
def test_env_var_set_routes_to_declarative_path(self, mock_init_components):
fake = _FakeConfigurationModule()
sentinel_config = object()
- fake.load_config_file.return_value = sentinel_config
+ fake.load_config.return_value = sentinel_config
with patch.dict("sys.modules", {"opentelemetry.configuration": fake}):
_OTelSDKConfigurator()._configure()
- fake.load_config_file.assert_called_once_with("/tmp/otel.yaml")
+ fake.load_config.assert_called_once_with("/tmp/otel.yaml")
fake.configure_sdk.assert_called_once_with(sentinel_config)
mock_init_components.assert_not_called()
@@ -73,7 +73,6 @@ def test_env_var_set_but_package_missing_raises(self, mock_init_components):
@patch.dict("os.environ", {OTEL_CONFIG_FILE: "/tmp/otel.yaml"})
def test_env_var_set_with_kwargs_warns_and_ignores(self):
fake = _FakeConfigurationModule()
- fake.load_config_file.return_value = object()
with patch.dict("sys.modules", {"opentelemetry.configuration": fake}):
with self.assertLogs("opentelemetry.sdk._configuration", level="WARNING") as captured:
diff --git a/opentelemetry-sdk/tests/metrics/test_metrics.py b/opentelemetry-sdk/tests/metrics/test_metrics.py
index 47db7bc6489..5c2ea07da78 100644
--- a/opentelemetry-sdk/tests/metrics/test_metrics.py
+++ b/opentelemetry-sdk/tests/metrics/test_metrics.py
@@ -554,6 +554,41 @@ def test_addition_of_metric_reader(self):
meter_provider.remove_metric_reader(reader)
self.assertIn("has not been registered!", cm.output[0])
+ def test_add_metric_reader_sets_meter_provider(self):
+ reader = InMemoryMetricReader()
+ meter_provider = MeterProvider()
+
+ with patch.object(reader, "_set_meter_provider") as mock_set_meter_provider:
+ meter_provider.add_metric_reader(reader)
+
+ mock_set_meter_provider.assert_called_once_with(meter_provider)
+
+ def test_added_metric_reader_is_flushed_and_shut_down(self):
+ reader = InMemoryMetricReader()
+ meter_provider = MeterProvider(shutdown_on_exit=False)
+ meter_provider.add_metric_reader(reader)
+
+ with (
+ patch.object(reader, "force_flush") as mock_force_flush,
+ patch.object(reader, "shutdown") as mock_shutdown,
+ ):
+ meter_provider.force_flush()
+ meter_provider.shutdown()
+
+ mock_force_flush.assert_called_once()
+ mock_shutdown.assert_called_once()
+
+ def test_removed_metric_reader_is_not_flushed(self):
+ reader = InMemoryMetricReader()
+ meter_provider = MeterProvider(shutdown_on_exit=False)
+ meter_provider.add_metric_reader(reader)
+ meter_provider.remove_metric_reader(reader)
+
+ with patch.object(reader, "force_flush") as mock_force_flush:
+ meter_provider.force_flush()
+
+ mock_force_flush.assert_not_called()
+
class TestMeterConcurrency(ConcurrencyTestBase, TestCase):
def test_create_instrument_concurrency(self):
@@ -603,6 +638,32 @@ def make_create_instrument(meter: Meter, method_name: str, args: list[Any]) -> C
for instr in instruments:
self.assertIs(instr, instruments[0])
+ def test_add_and_remove_metric_reader_concurrency(self):
+ """
+ Tests that concurrently adding and removing metric readers does not
+ lose any update to the provider's metric readers.
+ """
+ num_threads = 50
+ meter_provider = MeterProvider(shutdown_on_exit=False)
+ readers = [InMemoryMetricReader() for _ in range(num_threads)]
+ lock = Lock()
+
+ def make_update(update: Callable[[InMemoryMetricReader], None]) -> Callable[[], None]:
+ pending = list(readers)
+
+ def run() -> None:
+ with lock:
+ reader = pending.pop()
+ update(reader)
+
+ return run
+
+ self.run_with_many_threads(make_update(meter_provider.add_metric_reader), num_threads=num_threads)
+ self.assertCountEqual(meter_provider._metric_readers, readers)
+
+ self.run_with_many_threads(make_update(meter_provider.remove_metric_reader), num_threads=num_threads)
+ self.assertEqual(meter_provider._metric_readers, ())
+
class TestMeter(TestCase):
def setUp(self):
diff --git a/pyproject.toml b/pyproject.toml
index 24889e0705b..88870567201 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -201,7 +201,7 @@ use-title-as-name = true
use-union-operator = true
target-python-version = "3.10"
custom-template-dir = "opentelemetry-configuration/codegen"
-additional-imports = "typing.ClassVar,opentelemetry.configuration._common._additional_properties"
+additional-imports = "typing.ClassVar,opentelemetry.configuration._common._additional_properties,opentelemetry.configuration._common._exactly_one_property"
[tool.towncrier]
directory = ".changelog"
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_partial_config_not_applied/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_partial_config_not_applied/otel-config.yaml
new file mode 100644
index 00000000000..29f1fb72a2a
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_partial_config_not_applied/otel-config.yaml
@@ -0,0 +1,16 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-error-partial-config-not-applied
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+meter_provider:
+ readers:
+ - periodic:
+ exporter:
+ bogus_exporter: {}
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_partial_config_not_applied/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_partial_config_not_applied/run.py
new file mode 100644
index 00000000000..b2ebcfcbeb2
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_partial_config_not_applied/run.py
@@ -0,0 +1,11 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+print(f"tracer_provider={type(trace.get_tracer_provider()).__name__}")
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("error-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_partial_config_not_applied/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_partial_config_not_applied/scenario.yaml
new file mode 100644
index 00000000000..2d332eda331
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_partial_config_not_applied/scenario.yaml
@@ -0,0 +1,17 @@
+description: >
+ A meter exporter that fails to load fails configuration without installing
+ the tracer provider that was already created, so nothing is exported.
+until:
+ - exited: {}
+expect:
+ - output:
+ stream: stderr
+ pattern: Failed to auto initialize OpenTelemetry
+ - output:
+ stream: stderr
+ pattern: bogus_exporter
+ - output:
+ stream: stdout
+ pattern: "(?m)^tracer_provider=ProxyTracerProvider$"
+ - spans:
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_sampler_no_choice/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_sampler_no_choice/otel-config.yaml
new file mode 100644
index 00000000000..1901384d46c
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_sampler_no_choice/otel-config.yaml
@@ -0,0 +1,13 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-error-sampler-no-choice
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ sampler:
+ jaeger_remote/development:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_sampler_no_choice/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_sampler_no_choice/run.py
new file mode 100644
index 00000000000..26611934cd9
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_sampler_no_choice/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("error-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_sampler_no_choice/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_sampler_no_choice/scenario.yaml
new file mode 100644
index 00000000000..e67e960b1c5
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_sampler_no_choice/scenario.yaml
@@ -0,0 +1,14 @@
+description: >
+ A null `jaeger_remote/development` sampler passes the schema but selects no
+ sampler, which fails configuration.
+until:
+ - exited: {}
+expect:
+ - output:
+ stream: stderr
+ pattern: Failed to auto initialize OpenTelemetry
+ - output:
+ stream: stderr
+ pattern: Sampler requires exactly one of
+ - spans:
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/scenario.yaml
index 446480d62c6..bd1fc2cd52b 100644
--- a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/scenario.yaml
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/scenario.yaml
@@ -1,5 +1,5 @@
description: >
- `disabled: true` leaves the SDK unconfigured, so nothing is exported.
+ `disabled: true` installs no-op providers, so nothing is exported.
until:
- exited: {}
expect:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled_propagator/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled_propagator/otel-config.yaml
new file mode 100644
index 00000000000..64e6c88a577
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled_propagator/otel-config.yaml
@@ -0,0 +1,15 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-general-disabled-propagator
+disabled: true
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+propagator:
+ composite:
+ - baggage:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled_propagator/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled_propagator/run.py
new file mode 100644
index 00000000000..ce7dad8bc52
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled_propagator/run.py
@@ -0,0 +1,17 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import baggage, propagate, trace
+
+tracer = trace.get_tracer(__name__)
+
+incoming = {
+ "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
+ "baggage": "user.id=u-123",
+}
+extracted = propagate.extract(incoming)
+print(f"remote_span_valid={trace.get_current_span(extracted).get_span_context().is_valid}")
+print(f"baggage.user.id={baggage.get_baggage('user.id', extracted) or ''}")
+
+with tracer.start_as_current_span("disabled-span", context=extracted):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled_propagator/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled_propagator/scenario.yaml
new file mode 100644
index 00000000000..4ca522292b4
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled_propagator/scenario.yaml
@@ -0,0 +1,15 @@
+description: >
+ `disabled: true` installs no-op providers but keeps the configured
+ propagator: only baggage is extracted, the incoming traceparent is not, and
+ nothing is exported.
+until:
+ - exited: {}
+expect:
+ - output:
+ stream: stdout
+ pattern: "(?m)^baggage\\.user\\.id=u-123$"
+ - output:
+ stream: stdout
+ pattern: "(?m)^remote_span_valid=False$"
+ - spans:
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_omitted_providers_noop/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_omitted_providers_noop/otel-config.yaml
new file mode 100644
index 00000000000..2473c5b7f0f
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_omitted_providers_noop/otel-config.yaml
@@ -0,0 +1,11 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-general-omitted-providers-noop
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_omitted_providers_noop/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_omitted_providers_noop/run.py
new file mode 100644
index 00000000000..b4dd7b517ae
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_omitted_providers_noop/run.py
@@ -0,0 +1,12 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import _logs, metrics, trace
+
+print(f"meter_provider={type(metrics.get_meter_provider()).__name__}")
+print(f"logger_provider={type(_logs.get_logger_provider()).__name__}")
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("omitted-providers-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_omitted_providers_noop/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_omitted_providers_noop/scenario.yaml
new file mode 100644
index 00000000000..5dcdfba0e8f
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_omitted_providers_noop/scenario.yaml
@@ -0,0 +1,20 @@
+description: >
+ Omitted `meter_provider` and `logger_provider` sections install no-op
+ providers, so `OTEL_PYTHON_*_PROVIDER` doesn't select SDK providers instead.
+env:
+ OTEL_PYTHON_METER_PROVIDER: sdk_meter_provider
+ OTEL_PYTHON_LOGGER_PROVIDER: sdk_logger_provider
+until:
+ - spans:
+ name: omitted-providers-span
+ - exited: {}
+expect:
+ - output:
+ stream: stdout
+ pattern: "(?m)^meter_provider=NoOpMeterProvider$"
+ - output:
+ stream: stdout
+ pattern: "(?m)^logger_provider=NoOpLoggerProvider$"
+ - spans:
+ name: omitted-providers-span
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_self_observability/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_self_observability/otel-config.yaml
new file mode 100644
index 00000000000..d7d5292bc13
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_self_observability/otel-config.yaml
@@ -0,0 +1,19 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-general-self-observability
+tracer_provider:
+ processors:
+ - batch:
+ schedule_delay: 100
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ otlp_grpc:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4317
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_self_observability/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_self_observability/run.py
new file mode 100644
index 00000000000..a1b13be12db
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_self_observability/run.py
@@ -0,0 +1,19 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+import contextlib
+import threading
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+for _ in range(3):
+ with tracer.start_as_current_span("self-obs-span"):
+ pass
+
+# The metric exporter's and reader's own metrics are only reported on the next
+# collection, so stay alive for the periodic reader until the harness stops
+# the app.
+with contextlib.suppress(KeyboardInterrupt):
+ threading.Event().wait()
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_self_observability/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_self_observability/scenario.yaml
new file mode 100644
index 00000000000..19fe05fe5ea
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_self_observability/scenario.yaml
@@ -0,0 +1,62 @@
+description: >
+ With self-observability enabled, the SDK components built from declarative
+ config record their own metrics through the configured MeterProvider, so
+ they are exported with the configured resource.
+env:
+ OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED: "true"
+until:
+ - metric:
+ name: otel.sdk.exporter.metric_data_point.exported
+ min: 1
+ - metric:
+ name: otel.sdk.exporter.span.exported
+ min: 1
+ - metric:
+ name: otel.sdk.metric_reader.collection.duration
+ field: count
+ min: 1
+expect:
+ - resource:
+ attributes:
+ values:
+ service.name: declconfig-general-self-observability
+ - spans:
+ name: self-obs-span
+ min_count: 1
+ - metric:
+ name: otel.sdk.exporter.metric_data_point.exported
+ scope:
+ name: opentelemetry-sdk
+ data_point:
+ value: {gte: 1}
+ attributes:
+ values:
+ otel.component.type: otlp_grpc_metric_exporter
+ - metric:
+ name: otel.sdk.metric_reader.collection.duration
+ scope:
+ name: opentelemetry-sdk
+ data_point:
+ attributes:
+ values:
+ otel.component.type: periodic_metric_reader
+ - metric:
+ name: otel.sdk.exporter.span.exported
+ scope:
+ name: opentelemetry-sdk
+ data_point:
+ attributes:
+ values:
+ otel.component.type: otlp_http_span_exporter
+ - metric:
+ name: otel.sdk.processor.span.processed
+ scope:
+ name: opentelemetry-sdk
+ data_point:
+ attributes:
+ values:
+ otel.component.type: batching_span_processor
+ - metric:
+ name: otel.sdk.span.started
+ scope:
+ name: opentelemetry-sdk
diff --git a/tox.ini b/tox.ini
index 9b0a2e6743e..c364f31a4ae 100644
--- a/tox.ini
+++ b/tox.ini
@@ -495,7 +495,8 @@ allowlist_externals =
datamodel-codegen
python
commands =
- datamodel-codegen
+ python {toxinidir}/opentelemetry-configuration/codegen/one_of_template_data.py {toxinidir}/opentelemetry-configuration/src/opentelemetry/configuration/schema.json {envtmpdir}/extra_template_data.json
+ datamodel-codegen --extra-template-data {envtmpdir}/extra_template_data.json
[testenv:shellcheck]
commands_pre =