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 =