diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5f0c4452..85f7f0e115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `opentelemetry-sdk`: Add shared `_parse_headers` helper for declarative config OTLP exporters ([#5021](https://github.com/open-telemetry/opentelemetry-python/pull/5021)) - `opentelemetry-api`: Replace a broad exception in attribute cleaning tests to satisfy pylint in the `lint-opentelemetry-api` CI job +- `opentelemetry-sdk`: Add `create_meter_provider`/`configure_meter_provider` to declarative file configuration, enabling MeterProvider instantiation from config files without reading env vars + ([#4987](https://github.com/open-telemetry/opentelemetry-python/pull/4987)) - `opentelemetry-sdk`: Add `create_resource` and `create_propagator`/`configure_propagator` to declarative file configuration, enabling Resource and propagator instantiation from config files without reading env vars ([#4979](https://github.com/open-telemetry/opentelemetry-python/pull/4979)) - `opentelemetry-sdk`: Map Python `CRITICAL` log level to OTel `FATAL` severity text per the specification diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py new file mode 100644 index 0000000000..257351135f --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py @@ -0,0 +1,484 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import logging +from typing import Optional, Set, Type + +from opentelemetry import metrics +from opentelemetry.sdk._configuration._common import _parse_headers +from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + Aggregation as AggregationConfig, +) +from opentelemetry.sdk._configuration.models import ( + ConsoleMetricExporter as ConsoleMetricExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExemplarFilter as ExemplarFilterConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExporterDefaultHistogramAggregation, + ExporterTemporalityPreference, + InstrumentType, +) +from opentelemetry.sdk._configuration.models import ( + MeterProvider as MeterProviderConfig, +) +from opentelemetry.sdk._configuration.models import ( + MetricReader as MetricReaderConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpGrpcMetricExporter as OtlpGrpcMetricExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpHttpMetricExporter as OtlpHttpMetricExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + PeriodicMetricReader as PeriodicMetricReaderConfig, +) +from opentelemetry.sdk._configuration.models import ( + PushMetricExporter as PushMetricExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + View as ViewConfig, +) +from opentelemetry.sdk.metrics import ( + AlwaysOffExemplarFilter, + AlwaysOnExemplarFilter, + Counter, + Histogram, + MeterProvider, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + TraceBasedExemplarFilter, + UpDownCounter, + _Gauge, +) +from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + ConsoleMetricExporter, + MetricExporter, + MetricReader, + PeriodicExportingMetricReader, +) +from opentelemetry.sdk.metrics.view import ( + Aggregation, + DefaultAggregation, + DropAggregation, + ExplicitBucketHistogramAggregation, + ExponentialBucketHistogramAggregation, + LastValueAggregation, + SumAggregation, + View, +) +from opentelemetry.sdk.resources import Resource + +_logger = logging.getLogger(__name__) + + +# Default interval/timeout per OTel spec (milliseconds). +_DEFAULT_EXPORT_INTERVAL_MILLIS = 60000 +_DEFAULT_EXPORT_TIMEOUT_MILLIS = 30000 + +# Instrument type → SDK instrument class mapping (for View selectors). +_INSTRUMENT_TYPE_MAP: dict[InstrumentType, Type] = { + InstrumentType.counter: Counter, + InstrumentType.up_down_counter: UpDownCounter, + InstrumentType.histogram: Histogram, + InstrumentType.gauge: _Gauge, + InstrumentType.observable_counter: ObservableCounter, + InstrumentType.observable_gauge: ObservableGauge, + InstrumentType.observable_up_down_counter: ObservableUpDownCounter, +} + + +def _map_temporality( + pref: Optional[ExporterTemporalityPreference], +) -> dict[type, AggregationTemporality]: + """Map a temporality preference to an explicit preferred_temporality dict. + + Always returns an explicit dict to suppress OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE. + Default (None or cumulative) → all instruments CUMULATIVE. + """ + if pref is None or pref == ExporterTemporalityPreference.cumulative: + return { + Counter: AggregationTemporality.CUMULATIVE, + UpDownCounter: AggregationTemporality.CUMULATIVE, + Histogram: AggregationTemporality.CUMULATIVE, + ObservableCounter: AggregationTemporality.CUMULATIVE, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, + } + if pref == ExporterTemporalityPreference.delta: + return { + Counter: AggregationTemporality.DELTA, + UpDownCounter: AggregationTemporality.CUMULATIVE, + Histogram: AggregationTemporality.DELTA, + ObservableCounter: AggregationTemporality.DELTA, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, + } + if pref == ExporterTemporalityPreference.low_memory: + return { + Counter: AggregationTemporality.DELTA, + UpDownCounter: AggregationTemporality.CUMULATIVE, + Histogram: AggregationTemporality.DELTA, + ObservableCounter: AggregationTemporality.CUMULATIVE, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, + } + raise ConfigurationError( + f"Unsupported temporality preference '{pref}'. " + "Supported values: cumulative, delta, low_memory." + ) + + +def _map_histogram_aggregation( + pref: Optional[ExporterDefaultHistogramAggregation], +) -> dict[type, Aggregation]: + """Map a histogram aggregation preference to an explicit preferred_aggregation dict. + + Always returns an explicit dict to suppress + OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION. + Default (None or explicit_bucket_histogram) → ExplicitBucketHistogramAggregation. + """ + if ( + pref is None + or pref + == ExporterDefaultHistogramAggregation.explicit_bucket_histogram + ): + return {Histogram: ExplicitBucketHistogramAggregation()} + if ( + pref + == ExporterDefaultHistogramAggregation.base2_exponential_bucket_histogram + ): + return {Histogram: ExponentialBucketHistogramAggregation()} + raise ConfigurationError( + f"Unsupported default histogram aggregation '{pref}'. " + "Supported values: explicit_bucket_histogram, base2_exponential_bucket_histogram." + ) + + +def _create_aggregation(config: AggregationConfig) -> Aggregation: + """Create an SDK Aggregation from config, passing through detail parameters.""" + if config.default is not None: + return DefaultAggregation() + if config.drop is not None: + return DropAggregation() + if config.explicit_bucket_histogram is not None: + return ExplicitBucketHistogramAggregation( + boundaries=config.explicit_bucket_histogram.boundaries, + record_min_max=( + config.explicit_bucket_histogram.record_min_max + if config.explicit_bucket_histogram.record_min_max is not None + else True + ), + ) + if config.base2_exponential_bucket_histogram is not None: + kwargs = {} + if config.base2_exponential_bucket_histogram.max_size is not None: + kwargs["max_size"] = ( + config.base2_exponential_bucket_histogram.max_size + ) + if config.base2_exponential_bucket_histogram.max_scale is not None: + kwargs["max_scale"] = ( + config.base2_exponential_bucket_histogram.max_scale + ) + return ExponentialBucketHistogramAggregation(**kwargs) + if config.last_value is not None: + return LastValueAggregation() + if config.sum is not None: + return SumAggregation() + raise ConfigurationError( + f"Unknown or unsupported aggregation type in config: {config!r}. " + "Supported types: default, drop, explicit_bucket_histogram, " + "base2_exponential_bucket_histogram, last_value, sum." + ) + + +def _create_view(config: ViewConfig) -> View: + """Create an SDK View from config.""" + selector = config.selector + stream = config.stream + + instrument_type = None + if selector.instrument_type is not None: + instrument_type = _INSTRUMENT_TYPE_MAP.get(selector.instrument_type) + if instrument_type is None: + raise ConfigurationError( + f"Unknown instrument type: {selector.instrument_type!r}" + ) + + attribute_keys: Optional[Set[str]] = None + if stream.attribute_keys is not None: + if stream.attribute_keys.excluded: + _logger.warning( + "attribute_keys.excluded is not supported by the Python SDK View; " + "the exclusion list will be ignored." + ) + if stream.attribute_keys.included is not None: + attribute_keys = set(stream.attribute_keys.included) + + aggregation = None + if stream.aggregation is not None: + aggregation = _create_aggregation(stream.aggregation) + + return View( + instrument_type=instrument_type, + instrument_name=selector.instrument_name, + meter_name=selector.meter_name, + meter_version=selector.meter_version, + meter_schema_url=selector.meter_schema_url, + instrument_unit=selector.unit, + name=stream.name, + description=stream.description, + attribute_keys=attribute_keys, + aggregation=aggregation, + ) + + +def _create_console_metric_exporter( + config: ConsoleMetricExporterConfig, +) -> MetricExporter: + """Create a ConsoleMetricExporter from config.""" + preferred_temporality = _map_temporality(config.temporality_preference) + preferred_aggregation = _map_histogram_aggregation( + config.default_histogram_aggregation + ) + return ConsoleMetricExporter( + preferred_temporality=preferred_temporality, + preferred_aggregation=preferred_aggregation, + ) + + +def _map_compression_metric( + value: Optional[str], compression_enum: type +) -> Optional[object]: + """Map a compression string to the given Compression enum value.""" + if value is None or value.lower() == "none": + return None + if value.lower() == "gzip": + return compression_enum.Gzip # type: ignore[attr-defined] + raise ConfigurationError( + f"Unsupported compression value '{value}'. Supported values: 'gzip', 'none'." + ) + + +def _create_otlp_http_metric_exporter( + config: OtlpHttpMetricExporterConfig, +) -> MetricExporter: + """Create an OTLP HTTP metric exporter from config.""" + try: + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.exporter.otlp.proto.http import ( # type: ignore[import-untyped] # noqa: PLC0415 + Compression, + ) + from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415 + OTLPMetricExporter, + ) + except ImportError as exc: + raise ConfigurationError( + "otlp_http metric exporter requires 'opentelemetry-exporter-otlp-proto-http'. " + "Install it with: pip install opentelemetry-exporter-otlp-proto-http" + ) from exc + + compression = _map_compression_metric(config.compression, Compression) + 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) + preferred_aggregation = _map_histogram_aggregation( + config.default_histogram_aggregation + ) + + return OTLPMetricExporter( # type: ignore[return-value] + endpoint=config.endpoint, + headers=headers, + timeout=timeout, + compression=compression, # type: ignore[arg-type] + preferred_temporality=preferred_temporality, + preferred_aggregation=preferred_aggregation, + ) + + +def _create_otlp_grpc_metric_exporter( + config: OtlpGrpcMetricExporterConfig, +) -> MetricExporter: + """Create an OTLP gRPC metric exporter from config.""" + try: + # pylint: disable=import-outside-toplevel,no-name-in-module + import grpc # type: ignore[import-untyped] # noqa: PLC0415 + + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415 + OTLPMetricExporter, + ) + except ImportError as exc: + raise ConfigurationError( + "otlp_grpc metric exporter requires 'opentelemetry-exporter-otlp-proto-grpc'. " + "Install it with: pip install opentelemetry-exporter-otlp-proto-grpc" + ) from exc + + compression = _map_compression_metric(config.compression, grpc.Compression) + 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) + preferred_aggregation = _map_histogram_aggregation( + config.default_histogram_aggregation + ) + + return OTLPMetricExporter( # type: ignore[return-value] + endpoint=config.endpoint, + headers=headers, + timeout=timeout, + compression=compression, # type: ignore[arg-type] + preferred_temporality=preferred_temporality, + preferred_aggregation=preferred_aggregation, + ) + + +def _create_push_metric_exporter( + config: PushMetricExporterConfig, +) -> MetricExporter: + """Create a push metric exporter from config.""" + if config.console is not None: + return _create_console_metric_exporter(config.console) + if config.otlp_http is not None: + return _create_otlp_http_metric_exporter(config.otlp_http) + if config.otlp_grpc is not None: + return _create_otlp_grpc_metric_exporter(config.otlp_grpc) + if config.otlp_file_development is not None: + raise ConfigurationError( + "otlp_file_development metric exporter is experimental and not yet supported." + ) + raise ConfigurationError( + "No exporter type specified in push metric exporter config. " + "Supported types: console, otlp_http, otlp_grpc." + ) + + +def _create_periodic_metric_reader( + config: PeriodicMetricReaderConfig, +) -> PeriodicExportingMetricReader: + """Create a PeriodicExportingMetricReader from config. + + Passes explicit interval/timeout defaults to suppress env var reading. + """ + exporter = _create_push_metric_exporter(config.exporter) + 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( + exporter=exporter, + export_interval_millis=float(interval), + export_timeout_millis=float(timeout), + ) + + +def _create_metric_reader(config: MetricReaderConfig) -> MetricReader: + """Create a MetricReader from config.""" + if config.periodic is not None: + return _create_periodic_metric_reader(config.periodic) + if config.pull is not None: + raise ConfigurationError( + "Pull metric readers (e.g. Prometheus) are experimental and not yet supported " + "by declarative config. Use the SDK API directly to configure pull readers." + ) + raise ConfigurationError( + "No reader type specified in metric reader config. " + "Supported types: periodic." + ) + + +def _create_exemplar_filter( + value: ExemplarFilterConfig, +) -> object: + """Create an SDK exemplar filter from config enum value.""" + if value == ExemplarFilterConfig.always_on: + return AlwaysOnExemplarFilter() + if value == ExemplarFilterConfig.always_off: + return AlwaysOffExemplarFilter() + if value == ExemplarFilterConfig.trace_based: + return TraceBasedExemplarFilter() + raise ConfigurationError( + f"Unknown exemplar filter value: {value!r}. " + "Supported values: always_on, always_off, trace_based." + ) + + +def create_meter_provider( + config: Optional[MeterProviderConfig], + resource: Optional[Resource] = None, +) -> MeterProvider: + """Create an SDK MeterProvider from declarative config. + + Does NOT read OTEL_METRIC_EXPORT_INTERVAL, OTEL_METRICS_EXEMPLAR_FILTER, + or any other env vars for values explicitly controlled by the config. + Absent config values use OTel spec defaults, matching Java SDK behavior. + + Args: + config: MeterProvider config from the parsed config file, or None. + resource: Resource to attach to the provider. + + Returns: + A configured MeterProvider. + """ + # Always pass an explicit exemplar filter to suppress env var reading. + # Spec default is trace_based. + exemplar_filter: object = TraceBasedExemplarFilter() + 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)) + + return MeterProvider( + resource=resource, + metric_readers=readers, + exemplar_filter=exemplar_filter, # type: ignore[arg-type] + views=views, + ) + + +def configure_meter_provider( + config: Optional[MeterProviderConfig], + resource: Optional[Resource] = 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)) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py index 60cc904f13..3c6372bb73 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py @@ -36,13 +36,16 @@ def _load_entry_point_propagator(name: str) -> TextMapPropagator: """Load a propagator by name from the opentelemetry_propagator entry point group.""" try: - eps = list(entry_points(group="opentelemetry_propagator", name=name)) - if not eps: + ep = next( + iter(entry_points(group="opentelemetry_propagator", name=name)), + None, + ) + if not ep: raise ConfigurationError( f"Propagator '{name}' not found. " "It may not be installed or may be misspelled." ) - return eps[0].load()() + return ep.load()() except ConfigurationError: raise except Exception as exc: @@ -85,19 +88,13 @@ def create_propagator( if config is None: return CompositePropagator([]) - propagators: list[TextMapPropagator] = [] - seen_types: set[type] = set() - - def _add_deduped(propagator: TextMapPropagator) -> None: - if type(propagator) not in seen_types: - seen_types.add(type(propagator)) - propagators.append(propagator) + propagators: dict[type[TextMapPropagator], TextMapPropagator] = {} # Process structured composite list if config.composite: for entry in config.composite: for propagator in _propagators_from_textmap_config(entry): - _add_deduped(propagator) + propagators.setdefault(type(propagator), propagator) # Process composite_list (comma-separated propagator names via entry_points) if config.composite_list: @@ -105,9 +102,10 @@ def _add_deduped(propagator: TextMapPropagator) -> None: name = name.strip() if not name or name.lower() == "none": continue - _add_deduped(_load_entry_point_propagator(name)) + propagator = _load_entry_point_propagator(name) + propagators.setdefault(type(propagator), propagator) - return CompositePropagator(propagators) + return CompositePropagator(list(propagators.values())) def configure_propagator(config: Optional[PropagatorConfig]) -> None: diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index d58bd4d31d..66e13a3145 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -170,13 +170,9 @@ def _filter_attributes( if not included and not excluded: return attrs - effective_included = included if included else None # [] → include all - result: dict[str, object] = {} for key, value in attrs.items(): - if effective_included is not None and not any( - fnmatch.fnmatch(key, pat) for pat in effective_included - ): + if included and not any(fnmatch.fnmatch(key, pat) for pat in included): continue if excluded and any(fnmatch.fnmatch(key, pat) for pat in excluded): continue diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py index 664bb8bc29..ded64617a0 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py @@ -25,6 +25,10 @@ """ from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration._meter_provider import ( + configure_meter_provider, + create_meter_provider, +) from opentelemetry.sdk._configuration._propagator import ( configure_propagator, create_propagator, @@ -44,4 +48,6 @@ "create_resource", "create_propagator", "configure_propagator", + "create_meter_provider", + "configure_meter_provider", ] diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py new file mode 100644 index 0000000000..04d60847f0 --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -0,0 +1,640 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Tests access private members of SDK classes to assert correct configuration. +# pylint: disable=protected-access + +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +from opentelemetry.sdk._configuration._meter_provider import ( + configure_meter_provider, + create_meter_provider, +) +from opentelemetry.sdk._configuration.file._loader import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + Aggregation as AggregationConfig, +) +from opentelemetry.sdk._configuration.models import ( + Base2ExponentialBucketHistogramAggregation as Base2Config, +) +from opentelemetry.sdk._configuration.models import ( + ConsoleMetricExporter as ConsoleMetricExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExemplarFilter as ExemplarFilterConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExplicitBucketHistogramAggregation as ExplicitBucketConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExporterDefaultHistogramAggregation, + ExporterTemporalityPreference, + IncludeExclude, + InstrumentType, + ViewSelector, + ViewStream, +) +from opentelemetry.sdk._configuration.models import ( + MeterProvider as MeterProviderConfig, +) +from opentelemetry.sdk._configuration.models import ( + MetricReader as MetricReaderConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpGrpcMetricExporter as OtlpGrpcMetricExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpHttpMetricExporter as OtlpHttpMetricExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + PeriodicMetricReader as PeriodicMetricReaderConfig, +) +from opentelemetry.sdk._configuration.models import ( + PushMetricExporter as PushMetricExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + View as ViewConfig, +) +from opentelemetry.sdk.metrics import ( + AlwaysOffExemplarFilter, + AlwaysOnExemplarFilter, + Counter, + Histogram, + MeterProvider, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + TraceBasedExemplarFilter, + UpDownCounter, +) +from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + ConsoleMetricExporter, + PeriodicExportingMetricReader, +) +from opentelemetry.sdk.metrics.view import ( + DefaultAggregation, + DropAggregation, + ExplicitBucketHistogramAggregation, + ExponentialBucketHistogramAggregation, + LastValueAggregation, + SumAggregation, + View, +) +from opentelemetry.sdk.resources import Resource + + +class TestCreateMeterProviderBasic(unittest.TestCase): + def test_none_config_returns_provider(self): + provider = create_meter_provider(None) + self.assertIsInstance(provider, MeterProvider) + + def test_none_config_uses_supplied_resource(self): + resource = Resource({"service.name": "svc"}) + provider = create_meter_provider(None, resource) + self.assertIs(provider._sdk_config.resource, resource) + + def test_none_config_no_readers(self): + provider = create_meter_provider(None) + self.assertEqual(len(provider._sdk_config.metric_readers), 0) + + def test_none_config_uses_trace_based_exemplar_filter(self): + provider = create_meter_provider(None) + self.assertIsInstance( + provider._sdk_config.exemplar_filter, TraceBasedExemplarFilter + ) + + def test_none_config_does_not_read_exemplar_filter_env_var(self): + with patch.dict( + os.environ, {"OTEL_METRICS_EXEMPLAR_FILTER": "always_on"} + ): + provider = create_meter_provider(None) + self.assertIsInstance( + provider._sdk_config.exemplar_filter, TraceBasedExemplarFilter + ) + + def test_none_config_does_not_read_interval_env_var(self): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + periodic=PeriodicMetricReaderConfig( + exporter=PushMetricExporterConfig( + console=ConsoleMetricExporterConfig() + ) + ) + ) + ] + ) + with patch.dict(os.environ, {"OTEL_METRIC_EXPORT_INTERVAL": "999999"}): + provider = create_meter_provider(config) + reader = provider._sdk_config.metric_readers[0] + 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.sdk._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) + + def test_empty_readers_list(self): + config = MeterProviderConfig(readers=[]) + provider = create_meter_provider(config) + self.assertEqual(len(provider._sdk_config.metric_readers), 0) + + +class TestCreateMetricReaders(unittest.TestCase): + @staticmethod + def _make_periodic_config(exporter_config, interval=None, timeout=None): + return MeterProviderConfig( + readers=[ + MetricReaderConfig( + periodic=PeriodicMetricReaderConfig( + exporter=exporter_config, + interval=interval, + timeout=timeout, + ) + ) + ] + ) + + def test_console_exporter(self): + config = self._make_periodic_config( + PushMetricExporterConfig(console=ConsoleMetricExporterConfig()) + ) + provider = create_meter_provider(config) + reader = provider._sdk_config.metric_readers[0] + self.assertIsInstance(reader, PeriodicExportingMetricReader) + self.assertIsInstance(reader._exporter, ConsoleMetricExporter) + + def test_periodic_reader_default_interval(self): + config = self._make_periodic_config( + PushMetricExporterConfig(console=ConsoleMetricExporterConfig()) + ) + provider = create_meter_provider(config) + reader = provider._sdk_config.metric_readers[0] + self.assertEqual(reader._export_interval_millis, 60000.0) + + def test_periodic_reader_default_timeout(self): + config = self._make_periodic_config( + PushMetricExporterConfig(console=ConsoleMetricExporterConfig()) + ) + provider = create_meter_provider(config) + reader = provider._sdk_config.metric_readers[0] + self.assertEqual(reader._export_timeout_millis, 30000.0) + + def test_periodic_reader_explicit_interval(self): + config = self._make_periodic_config( + PushMetricExporterConfig(console=ConsoleMetricExporterConfig()), + interval=5000, + ) + provider = create_meter_provider(config) + reader = provider._sdk_config.metric_readers[0] + self.assertEqual(reader._export_interval_millis, 5000.0) + + def test_periodic_reader_explicit_timeout(self): + config = self._make_periodic_config( + PushMetricExporterConfig(console=ConsoleMetricExporterConfig()), + timeout=10000, + ) + provider = create_meter_provider(config) + reader = provider._sdk_config.metric_readers[0] + self.assertEqual(reader._export_timeout_millis, 10000.0) + + def test_otlp_http_missing_package_raises(self): + config = self._make_periodic_config( + PushMetricExporterConfig(otlp_http=OtlpHttpMetricExporterConfig()) + ) + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http.metric_exporter": None, + "opentelemetry.exporter.otlp.proto.http": None, + }, + ): + with self.assertRaises(ConfigurationError) as ctx: + create_meter_provider(config) + self.assertIn("otlp-proto-http", str(ctx.exception)) + + def test_otlp_http_created_with_endpoint(self): + mock_exporter_cls = MagicMock() + mock_compression_cls = MagicMock() + mock_http_module = MagicMock() + mock_http_module.Compression = mock_compression_cls + mock_module = MagicMock() + mock_module.OTLPMetricExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http.metric_exporter": mock_module, + "opentelemetry.exporter.otlp.proto.http": mock_http_module, + }, + ): + config = self._make_periodic_config( + PushMetricExporterConfig( + otlp_http=OtlpHttpMetricExporterConfig( + endpoint="http://localhost:4318" + ) + ) + ) + create_meter_provider(config) + + _, kwargs = mock_exporter_cls.call_args + self.assertEqual(kwargs["endpoint"], "http://localhost:4318") + self.assertIsNone(kwargs["headers"]) + self.assertIsNone(kwargs["timeout"]) + self.assertIsNone(kwargs["compression"]) + + def test_otlp_grpc_missing_package_raises(self): + config = self._make_periodic_config( + PushMetricExporterConfig(otlp_grpc=OtlpGrpcMetricExporterConfig()) + ) + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.grpc.metric_exporter": None, + "grpc": None, + }, + ): + with self.assertRaises(ConfigurationError) as ctx: + create_meter_provider(config) + self.assertIn("otlp-proto-grpc", str(ctx.exception)) + + def test_pull_reader_raises(self): + config = MeterProviderConfig( + readers=[MetricReaderConfig(pull=MagicMock())] + ) + with self.assertRaises(ConfigurationError): + create_meter_provider(config) + + def test_no_reader_type_raises(self): + config = MeterProviderConfig(readers=[MetricReaderConfig()]) + with self.assertRaises(ConfigurationError): + create_meter_provider(config) + + def test_no_exporter_type_raises(self): + config = self._make_periodic_config(PushMetricExporterConfig()) + with self.assertRaises(ConfigurationError): + create_meter_provider(config) + + def test_multiple_readers(self): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + periodic=PeriodicMetricReaderConfig( + exporter=PushMetricExporterConfig( + console=ConsoleMetricExporterConfig() + ) + ) + ), + MetricReaderConfig( + periodic=PeriodicMetricReaderConfig( + exporter=PushMetricExporterConfig( + console=ConsoleMetricExporterConfig() + ) + ) + ), + ] + ) + provider = create_meter_provider(config) + self.assertEqual(len(provider._sdk_config.metric_readers), 2) + + +class TestTemporalityAndAggregation(unittest.TestCase): + @staticmethod + def _make_console_config(temporality=None, histogram_agg=None): + return MeterProviderConfig( + readers=[ + MetricReaderConfig( + periodic=PeriodicMetricReaderConfig( + exporter=PushMetricExporterConfig( + console=ConsoleMetricExporterConfig( + temporality_preference=temporality, + default_histogram_aggregation=histogram_agg, + ) + ) + ) + ) + ] + ) + + @staticmethod + def _get_exporter(config): + provider = create_meter_provider(config) + return provider._sdk_config.metric_readers[0]._exporter + + def test_default_temporality_is_cumulative(self): + exporter = self._get_exporter(self._make_console_config()) + for instrument_type in ( + Counter, + UpDownCounter, + Histogram, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + ): + self.assertEqual( + exporter._preferred_temporality[instrument_type], + AggregationTemporality.CUMULATIVE, + ) + + def test_cumulative_temporality(self): + exporter = self._get_exporter( + self._make_console_config( + temporality=ExporterTemporalityPreference.cumulative + ) + ) + self.assertEqual( + exporter._preferred_temporality[Counter], + AggregationTemporality.CUMULATIVE, + ) + + def test_delta_temporality(self): + exporter = self._get_exporter( + self._make_console_config( + temporality=ExporterTemporalityPreference.delta + ) + ) + self.assertEqual( + exporter._preferred_temporality[Counter], + AggregationTemporality.DELTA, + ) + self.assertEqual( + exporter._preferred_temporality[Histogram], + AggregationTemporality.DELTA, + ) + self.assertEqual( + exporter._preferred_temporality[UpDownCounter], + AggregationTemporality.CUMULATIVE, + ) + self.assertEqual( + exporter._preferred_temporality[ObservableCounter], + AggregationTemporality.DELTA, + ) + + def test_low_memory_temporality(self): + exporter = self._get_exporter( + self._make_console_config( + temporality=ExporterTemporalityPreference.low_memory + ) + ) + self.assertEqual( + exporter._preferred_temporality[Counter], + AggregationTemporality.DELTA, + ) + self.assertEqual( + exporter._preferred_temporality[ObservableCounter], + AggregationTemporality.CUMULATIVE, + ) + + def test_default_histogram_aggregation_is_explicit(self): + exporter = self._get_exporter(self._make_console_config()) + self.assertIsInstance( + exporter._preferred_aggregation[Histogram], + ExplicitBucketHistogramAggregation, + ) + + def test_explicit_histogram_aggregation(self): + exporter = self._get_exporter( + self._make_console_config( + histogram_agg=ExporterDefaultHistogramAggregation.explicit_bucket_histogram + ) + ) + self.assertIsInstance( + exporter._preferred_aggregation[Histogram], + ExplicitBucketHistogramAggregation, + ) + + def test_base2_exponential_histogram_aggregation(self): + exporter = self._get_exporter( + self._make_console_config( + histogram_agg=ExporterDefaultHistogramAggregation.base2_exponential_bucket_histogram + ) + ) + self.assertIsInstance( + exporter._preferred_aggregation[Histogram], + ExponentialBucketHistogramAggregation, + ) + + def test_temporality_suppresses_env_var(self): + with patch.dict( + os.environ, + {"OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE": "DELTA"}, + ): + exporter = self._get_exporter(self._make_console_config()) + # Config has no preference → default cumulative, env var ignored + self.assertEqual( + exporter._preferred_temporality[Counter], + AggregationTemporality.CUMULATIVE, + ) + + +class TestCreateViews(unittest.TestCase): + @staticmethod + def _make_view_config(selector_kwargs=None, stream_kwargs=None): + selector = ViewSelector( + **(selector_kwargs or {"instrument_name": "*"}) + ) + stream = ViewStream(**(stream_kwargs or {})) + return MeterProviderConfig( + readers=[], + views=[ViewConfig(selector=selector, stream=stream)], + ) + + @staticmethod + def _get_view(config): + provider = create_meter_provider(config) + return provider._sdk_config.views[0] + + def test_view_created(self): + config = self._make_view_config() + provider = create_meter_provider(config) + self.assertEqual(len(provider._sdk_config.views), 1) + self.assertIsInstance(provider._sdk_config.views[0], View) + + def test_selector_instrument_name(self): + view = self._get_view( + self._make_view_config({"instrument_name": "my.metric"}) + ) + self.assertEqual(view._instrument_name, "my.metric") + + def test_selector_instrument_type(self): + view = self._get_view( + self._make_view_config({"instrument_type": InstrumentType.counter}) + ) + self.assertIs(view._instrument_type, Counter) + + def test_selector_meter_name(self): + view = self._get_view( + self._make_view_config({"meter_name": "my.meter"}) + ) + self.assertEqual(view._meter_name, "my.meter") + + def test_stream_name(self): + view = self._get_view( + self._make_view_config( + {"instrument_name": "my.metric"}, + stream_kwargs={"name": "renamed"}, + ) + ) + self.assertEqual(view._name, "renamed") + + def test_stream_description(self): + view = self._get_view( + self._make_view_config( + stream_kwargs={"description": "a description"} + ) + ) + self.assertEqual(view._description, "a description") + + def test_stream_attribute_keys_included(self): + view = self._get_view( + self._make_view_config( + stream_kwargs={ + "attribute_keys": IncludeExclude(included=["key1", "key2"]) + } + ) + ) + self.assertEqual(view._attribute_keys, {"key1", "key2"}) + + def test_stream_attribute_keys_excluded_logs_warning(self): + config = self._make_view_config( + stream_kwargs={"attribute_keys": IncludeExclude(excluded=["key1"])} + ) + with self.assertLogs( + "opentelemetry.sdk._configuration._meter_provider", level="WARNING" + ) as log: + create_meter_provider(config) + self.assertTrue(any("excluded" in msg for msg in log.output)) + + def test_stream_aggregation_drop(self): + view = self._get_view( + self._make_view_config( + stream_kwargs={"aggregation": AggregationConfig(drop={})} + ) + ) + self.assertIsInstance(view._aggregation, DropAggregation) + + def test_stream_aggregation_explicit_bucket_histogram_with_boundaries( + self, + ): + view = self._get_view( + self._make_view_config( + stream_kwargs={ + "aggregation": AggregationConfig( + explicit_bucket_histogram=ExplicitBucketConfig( + boundaries=[1.0, 5.0, 10.0] + ) + ) + } + ) + ) + self.assertIsInstance( + view._aggregation, ExplicitBucketHistogramAggregation + ) + self.assertEqual(list(view._aggregation._boundaries), [1.0, 5.0, 10.0]) + + def test_stream_aggregation_base2_exponential_with_params(self): + view = self._get_view( + self._make_view_config( + stream_kwargs={ + "aggregation": AggregationConfig( + base2_exponential_bucket_histogram=Base2Config( + max_size=64, max_scale=5 + ) + ) + } + ) + ) + self.assertIsInstance( + view._aggregation, ExponentialBucketHistogramAggregation + ) + + def test_stream_aggregation_last_value(self): + view = self._get_view( + self._make_view_config( + stream_kwargs={"aggregation": AggregationConfig(last_value={})} + ) + ) + self.assertIsInstance(view._aggregation, LastValueAggregation) + + def test_stream_aggregation_sum(self): + view = self._get_view( + self._make_view_config( + stream_kwargs={"aggregation": AggregationConfig(sum={})} + ) + ) + self.assertIsInstance(view._aggregation, SumAggregation) + + def test_stream_aggregation_default(self): + view = self._get_view( + self._make_view_config( + stream_kwargs={"aggregation": AggregationConfig(default={})} + ) + ) + self.assertIsInstance(view._aggregation, DefaultAggregation) + + +class TestExemplarFilter(unittest.TestCase): + @staticmethod + def _make_config(exemplar_filter): + return MeterProviderConfig(readers=[], exemplar_filter=exemplar_filter) + + def test_always_on(self): + provider = create_meter_provider( + self._make_config(ExemplarFilterConfig.always_on) + ) + self.assertIsInstance( + provider._sdk_config.exemplar_filter, AlwaysOnExemplarFilter + ) + + def test_always_off(self): + provider = create_meter_provider( + self._make_config(ExemplarFilterConfig.always_off) + ) + self.assertIsInstance( + provider._sdk_config.exemplar_filter, AlwaysOffExemplarFilter + ) + + def test_trace_based(self): + provider = create_meter_provider( + self._make_config(ExemplarFilterConfig.trace_based) + ) + self.assertIsInstance( + provider._sdk_config.exemplar_filter, TraceBasedExemplarFilter + ) + + def test_absent_defaults_to_trace_based(self): + provider = create_meter_provider(MeterProviderConfig(readers=[])) + self.assertIsInstance( + provider._sdk_config.exemplar_filter, TraceBasedExemplarFilter + )