From 9e0c7800adde42366c6dbfdbe08b5faec3b82fe9 Mon Sep 17 00:00:00 2001 From: Ravi Theja Date: Tue, 14 Apr 2026 02:58:53 +0530 Subject: [PATCH 1/6] fix(sdk): force_flush returns meaningful bool on MetricReader Fixes #5020 Signed-off-by: Ravi Theja --- CHANGELOG.md | 1 + .../sdk/metrics/_internal/export/__init__.py | 40 ++++++++++++------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6024431107..26c11704a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#5093](https://github.com/open-telemetry/opentelemetry-python/pull/5093)) - `opentelemetry-sdk`: fix YAML structure injection via environment variable substitution in declarative file configuration; values containing newlines are now emitted as quoted YAML scalars per spec requirement ([#5091](https://github.com/open-telemetry/opentelemetry-python/pull/5091)) +- `opentelemetry-sdk`: Fix `force_flush` on `MetricReader` and `PeriodicExportingMetricReader` to return a meaningful `bool` reflecting actual export success/failure instead of always returning `True`. Also fixes `detach(token)` not being called when export raises an exception. ([#5020](https://github.com/open-telemetry/opentelemetry-python/issues/5020)) - `opentelemetry-sdk`: Add `create_logger_provider`/`configure_logger_provider` to declarative file configuration, enabling LoggerProvider instantiation from config files without reading env vars ([#4990](https://github.com/open-telemetry/opentelemetry-python/pull/4990)) - `opentelemetry-sdk`: Add `service` resource detector support to declarative file configuration via `detection_development.detectors[].service` diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py index 66f327306a..22437bed41 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py @@ -336,7 +336,7 @@ def __init__( ) @final - def collect(self, timeout_millis: float = 10_000) -> None: + def collect(self, timeout_millis: float = 10_000) -> Optional[bool]: """Collects the metrics from the internal SDK state and invokes the `_receive_metrics` with the collection. @@ -361,10 +361,11 @@ def collect(self, timeout_millis: float = 10_000) -> None: self._metrics.record_collection(perf_counter() - start_time) if metrics is not None: - self._receive_metrics( + return self._receive_metrics( metrics, timeout_millis=timeout_millis, ) + return None @final def _set_collect_callback( @@ -386,8 +387,16 @@ def _receive_metrics( metrics_data: MetricsData, timeout_millis: float = 10_000, **kwargs, - ) -> None: - """Called by `MetricReader.collect` when it receives a batch of metrics""" + ) -> bool: + """Called by `MetricReader.collect` when it receives a batch of metrics. + + Subclasses must return ``True`` on success and ``False`` on failure. + + .. note:: + Existing subclasses that return ``None`` (the old implicit default) + will be treated as vacuous success by ``force_flush``, preserving + backward-compatible behaviour. + """ def _set_meter_provider(self, meter_provider: MeterProvider) -> None: self._metrics = MetricReaderMetrics( @@ -395,8 +404,8 @@ def _set_meter_provider(self, meter_provider: MeterProvider) -> None: ) def force_flush(self, timeout_millis: float = 10_000) -> bool: - self.collect(timeout_millis=timeout_millis) - return True + result = self.collect(timeout_millis=timeout_millis) + return result is not False @abstractmethod def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: @@ -449,9 +458,10 @@ def _receive_metrics( metrics_data: MetricsData, timeout_millis: float = 10_000, **kwargs, - ) -> None: + ) -> bool: with self._lock: self._metrics_data = metrics_data + return True def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: pass @@ -567,17 +577,19 @@ def _receive_metrics( metrics_data: MetricsData, timeout_millis: float = 10_000, **kwargs, - ) -> None: + ) -> bool: token = attach(set_value(_SUPPRESS_INSTRUMENTATION_KEY, True)) - # pylint: disable=broad-exception-caught,invalid-name try: with self._export_lock: - self._exporter.export( + result = self._exporter.export( metrics_data, timeout_millis=timeout_millis ) + return result is MetricExportResult.SUCCESS except Exception: _logger.exception("Exception while exporting metrics") - detach(token) + return False + finally: + detach(token) def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: deadline_ns = time_ns() + timeout_millis * 10**6 @@ -596,6 +608,6 @@ def _shutdown(): self._exporter.shutdown(timeout=(deadline_ns - time_ns()) / 10**6) def force_flush(self, timeout_millis: float = 10_000) -> bool: - super().force_flush(timeout_millis=timeout_millis) - self._exporter.force_flush(timeout_millis=timeout_millis) - return True + collect_ok = super().force_flush(timeout_millis=timeout_millis) + exporter_ok = self._exporter.force_flush(timeout_millis=timeout_millis) + return collect_ok and exporter_ok From 56807797f7d60c10d78b3cba29395bbf7437a196 Mon Sep 17 00:00:00 2001 From: Ravi Theja Date: Sat, 18 Apr 2026 03:14:40 +0530 Subject: [PATCH 2/6] test(sdk): add tests for force_flush meaningful bool return (#5020) --- .../sdk/metrics/_internal/export/__init__.py | 18 +++---- .../test_periodic_exporting_metric_reader.py | 52 ++++++++++++++++++- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py index 22437bed41..f530d03c73 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py @@ -23,7 +23,7 @@ from sys import stdout from threading import Event, Lock, RLock, Thread from time import perf_counter, time_ns -from typing import IO, Callable, Iterable, Optional +from typing import IO, Callable, Iterable from typing_extensions import final @@ -336,7 +336,7 @@ def __init__( ) @final - def collect(self, timeout_millis: float = 10_000) -> Optional[bool]: + def collect(self, timeout_millis: float = 10_000) -> bool | None: """Collects the metrics from the internal SDK state and invokes the `_receive_metrics` with the collection. @@ -387,7 +387,7 @@ def _receive_metrics( metrics_data: MetricsData, timeout_millis: float = 10_000, **kwargs, - ) -> bool: + ) -> bool | None: """Called by `MetricReader.collect` when it receives a batch of metrics. Subclasses must return ``True`` on success and ``False`` on failure. @@ -445,7 +445,7 @@ def __init__( def get_metrics_data( self, - ) -> Optional[MetricsData]: + ) -> MetricsData | None: """Reads and returns current metrics from the SDK""" with self._lock: self.collect() @@ -480,8 +480,8 @@ class PeriodicExportingMetricReader(MetricReader): def __init__( self, exporter: MetricExporter, - export_interval_millis: Optional[float] = None, - export_timeout_millis: Optional[float] = None, + export_interval_millis: float | None = None, + export_timeout_millis: float | None = None, ) -> None: # PeriodicExportingMetricReader defers to exporter for configuration super().__init__( @@ -608,6 +608,6 @@ def _shutdown(): self._exporter.shutdown(timeout=(deadline_ns - time_ns()) / 10**6) def force_flush(self, timeout_millis: float = 10_000) -> bool: - collect_ok = super().force_flush(timeout_millis=timeout_millis) - exporter_ok = self._exporter.force_flush(timeout_millis=timeout_millis) - return collect_ok and exporter_ok + if not super().force_flush(timeout_millis=timeout_millis): + return False + return self._exporter.force_flush(timeout_millis=timeout_millis) diff --git a/opentelemetry-sdk/tests/metrics/test_periodic_exporting_metric_reader.py b/opentelemetry-sdk/tests/metrics/test_periodic_exporting_metric_reader.py index 3e47e57768..81f9ed74e4 100644 --- a/opentelemetry-sdk/tests/metrics/test_periodic_exporting_metric_reader.py +++ b/opentelemetry-sdk/tests/metrics/test_periodic_exporting_metric_reader.py @@ -359,4 +359,54 @@ def test_metric_reader_metrics(self): assert isinstance(name, str) self.assertTrue(name.startswith("periodic_metric_reader/")) - mp.shutdown() + mp.shutdown() + + def test_force_flush_returns_true_on_success(self): + exporter = FakeMetricsExporter() + pmr = self._create_periodic_reader(metrics, exporter) + result = pmr.force_flush(timeout_millis=5_000) + self.assertTrue(result) + pmr.shutdown() + + def test_force_flush_returns_false_on_export_failure(self): + exporter = FakeMetricsExporter() + exporter.export = Mock(return_value=MetricExportResult.FAILURE) + pmr = self._create_periodic_reader(metrics, exporter) + result = pmr.force_flush(timeout_millis=5_000) + self.assertFalse(result) + pmr.shutdown() + + def test_force_flush_skips_exporter_flush_when_collect_fails(self): + exporter = FakeMetricsExporter() + exporter.force_flush = Mock(return_value=True) + pmr = PeriodicExportingMetricReader( + exporter, export_interval_millis=math.inf + ) + # No collect callback registered → collect returns None → force_flush + # on base treats None as not-False (success), so wire up a failing one + exporter.export = Mock(return_value=MetricExportResult.FAILURE) + + def _collect_failure(reader, timeout_millis): + return metrics + + pmr._set_collect_callback(_collect_failure) + exporter.export = Mock(return_value=MetricExportResult.FAILURE) + result = pmr.force_flush(timeout_millis=5_000) + self.assertFalse(result) + exporter.force_flush.assert_not_called() + pmr.shutdown() + + def test_detach_called_on_export_failure(self): + """detach(token) must run in finally even when export returns FAILURE.""" + from unittest.mock import patch + + exporter = FakeMetricsExporter() + exporter.export = Mock(return_value=MetricExportResult.FAILURE) + pmr = self._create_periodic_reader(metrics, exporter) + + with patch( + "opentelemetry.sdk.metrics._internal.export.detach" + ) as mock_detach: + pmr.force_flush(timeout_millis=5_000) + self.assertTrue(mock_detach.called) + pmr.shutdown() From 3533f03b59b1e21f7042f0f980ada50b9e1a1378 Mon Sep 17 00:00:00 2001 From: Ravi Theja Date: Tue, 21 Apr 2026 23:55:01 +0530 Subject: [PATCH 3/6] fix(sdk): address final review feedback - soften docstring, fix changelog PR link --- CHANGELOG.md | 2 +- .../src/opentelemetry/sdk/metrics/_internal/export/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 063b967388..09ed997b75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#5093](https://github.com/open-telemetry/opentelemetry-python/pull/5093)) - `opentelemetry-sdk`: fix YAML structure injection via environment variable substitution in declarative file configuration; values containing newlines are now emitted as quoted YAML scalars per spec requirement ([#5091](https://github.com/open-telemetry/opentelemetry-python/pull/5091)) -- `opentelemetry-sdk`: Fix `force_flush` on `MetricReader` and `PeriodicExportingMetricReader` to return a meaningful `bool` reflecting actual export success/failure instead of always returning `True`. Also fixes `detach(token)` not being called when export raises an exception. ([#5020](https://github.com/open-telemetry/opentelemetry-python/issues/5020)) +- `opentelemetry-sdk`: Fix `force_flush` on `MetricReader` and `PeriodicExportingMetricReader` to return a meaningful `bool` reflecting actual export success/failure instead of always returning `True`. Also fixes `detach(token)` not being called when export raises an exception. ([#5085](https://github.com/open-telemetry/opentelemetry-python/pull/5085)) - `opentelemetry-sdk`: Add `create_logger_provider`/`configure_logger_provider` to declarative file configuration, enabling LoggerProvider instantiation from config files without reading env vars ([#4990](https://github.com/open-telemetry/opentelemetry-python/pull/4990)) - `opentelemetry-sdk`: Add `service` resource detector support to declarative file configuration via `detection_development.detectors[].service` diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py index f530d03c73..2e1e4c4fc9 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py @@ -390,7 +390,7 @@ def _receive_metrics( ) -> bool | None: """Called by `MetricReader.collect` when it receives a batch of metrics. - Subclasses must return ``True`` on success and ``False`` on failure. + Subclasses should return ``True`` on success and ``False`` on failure. .. note:: Existing subclasses that return ``None`` (the old implicit default) From ec604fe73800bed427e11c0818edd8279d2716e3 Mon Sep 17 00:00:00 2001 From: Ravi Theja Date: Mon, 27 Apr 2026 03:57:51 +0530 Subject: [PATCH 4/6] fix(sdk): use explicit return None for consistency in collect() --- .../src/opentelemetry/sdk/metrics/_internal/export/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py index 2e1e4c4fc9..7a87411300 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py @@ -352,7 +352,7 @@ def collect(self, timeout_millis: float = 10_000) -> bool | None: _logger.warning( "Cannot call collect on a MetricReader until it is registered on a MeterProvider" ) - return + return None start_time = perf_counter() try: From 17bab68bba3894aa6587d369dc72ffb663aa5cf2 Mon Sep 17 00:00:00 2001 From: Ravi Theja Date: Mon, 27 Apr 2026 04:13:03 +0530 Subject: [PATCH 5/6] fix(sdk): move patch import to top level, fix trailing whitespace --- .../tests/metrics/test_periodic_exporting_metric_reader.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/opentelemetry-sdk/tests/metrics/test_periodic_exporting_metric_reader.py b/opentelemetry-sdk/tests/metrics/test_periodic_exporting_metric_reader.py index 81f9ed74e4..44ba8aa434 100644 --- a/opentelemetry-sdk/tests/metrics/test_periodic_exporting_metric_reader.py +++ b/opentelemetry-sdk/tests/metrics/test_periodic_exporting_metric_reader.py @@ -20,7 +20,7 @@ from logging import WARNING from time import sleep, time_ns from typing import Optional, cast -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest @@ -359,7 +359,7 @@ def test_metric_reader_metrics(self): assert isinstance(name, str) self.assertTrue(name.startswith("periodic_metric_reader/")) - mp.shutdown() + mp.shutdown() def test_force_flush_returns_true_on_success(self): exporter = FakeMetricsExporter() @@ -398,7 +398,6 @@ def _collect_failure(reader, timeout_millis): def test_detach_called_on_export_failure(self): """detach(token) must run in finally even when export returns FAILURE.""" - from unittest.mock import patch exporter = FakeMetricsExporter() exporter.export = Mock(return_value=MetricExportResult.FAILURE) From b45fe1de9a568e0b184bbbba14dda2ed49ca5eaf Mon Sep 17 00:00:00 2001 From: Ravi Theja Date: Tue, 28 Apr 2026 02:08:40 +0530 Subject: [PATCH 6/6] fix(sdk): suppress broad-exception-caught lint warning --- .../src/opentelemetry/sdk/metrics/_internal/export/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py index 7a87411300..41326b4c5a 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py @@ -585,7 +585,7 @@ def _receive_metrics( metrics_data, timeout_millis=timeout_millis ) return result is MetricExportResult.SUCCESS - except Exception: + except Exception: # pylint: disable=broad-exception-caught _logger.exception("Exception while exporting metrics") return False finally: