Repository: open-telemetry/opentelemetry-python

Issue:
[metrics] dictionary changed size during iteration
### Describe your environment

OS: Debian
Python version: 3.12.9 (main, Apr 8 2025, 01:41:13) [GCC 12.2.0]
SDK version: 1.38.0
API version: 1.38.0


### What happened?

We have gotten some errors on Sentry while exporting metrics because the dictionary changed size during iteration. 

The error happens on file `opentelemetry/sdk/metrics/_internal/metric_reader_storage.py` in `collect` at line 149.



### Steps to Reproduce

Unfortunately this happens about three times a day and we haven't found how to trigger it.

<img width="524" height="102" alt="Image" src="https://github.com/user-attachments/assets/e67d5d10-367a-4cc1-8944-76fc064efec6" />

### Expected Result

Metrics should be exported successfully.

### Actual Result

Sometimes the metrics fails with the error: `RuntimeError: dictionary changed size during iteration`

### Additional context

Stack trace
<img width="1233" height="504" alt="Image" src="https://github.com/user-attachments/assets/6b07f59f-9f2e-4cd6-a6f3-62b88f07306a" />

### Would you like to implement a fix?

None

### Tip

<sub>[React](https://github.blog/news-insights/product-news/add-reactions-to-pull-requests-issues-and-comments/) with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding `+1` or `me too`, to help us triage it. Learn more [here](https://opentelemetry.io/community/end-user/issue-participation/).</sub>

Discussion hints:

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f78c07cd9a..34138f4db7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -44,6 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   ([#4862](https://github.com/open-telemetry/opentelemetry-python/pull/4862))
 - `opentelemetry-exporter-otlp-proto-http`: fix retry logic and error handling for connection failures in trace, metric, and log exporters
   ([#4709](https://github.com/open-telemetry/opentelemetry-python/pull/4709))
+- `opentelemetry-sdk`: avoid RuntimeError during iteration of view instrument match dictionary in MetricReaderStorage.collect()
+  ([#4891](https://github.com/open-telemetry/opentelemetry-python/pull/4891))
 - Implement experimental TracerConfigurator
   ([#4861](https://github.com/open-telemetry/opentelemetry-python/pull/4861))
 - bump semantic-conventions to v1.39.0
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py
index f5121811eb..317fda0b42 100644
--- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py
@@ -143,10 +143,14 @@ def collect(self) -> Optional[MetricsData]:
                 InstrumentationScope, ScopeMetrics
             ] = {}
 
+            instrument_matches_snapshot = list(
+                self._instrument_view_instrument_matches.items()
+            )
+
             for (
                 instrument,
                 view_instrument_matches,
-            ) in self._instrument_view_instrument_matches.items():
+            ) in instrument_matches_snapshot:
                 aggregation_temporality = self._instrument_class_temporality[
                     instrument.__class__
                 ]


Test patch:
diff --git a/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py b/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py
index 7c9484b917..ec1456ae84 100644
--- a/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py
+++ b/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py
@@ -278,6 +278,44 @@ def send_measurement():
         # _ViewInstrumentMatch constructor should have only been called once
         self.assertEqual(mock_view_instrument_match_ctor.call_count, 1)
 
+    def test_race_collect_with_new_instruments(self):
+        storage = MetricReaderStorage(
+            SdkConfiguration(
+                exemplar_filter=Mock(),
+                resource=Mock(),
+                metric_readers=(),
+                views=(View(instrument_name="test"),),
+            ),
+            MagicMock(
+                **{
+                    "__getitem__.return_value": AggregationTemporality.CUMULATIVE
+                }
+            ),
+            MagicMock(**{"__getitem__.return_value": DefaultAggregation()}),
+        )
+
+        counter = _Counter("counter", Mock(), Mock())
+        storage.consume_measurement(
+            Measurement(1, time_ns(), counter, Context())
+        )
+
+        view_instrument_match = storage._instrument_view_instrument_matches[
+            counter
+        ][0]
+        original_collect = view_instrument_match.collect
+
+        new_counter = _Counter("new_counter", Mock(), Mock())
+
+        # Patch collect() to add a new counter during iteration
+        def collect_with_modification(*args, **kwargs):
+            storage._instrument_view_instrument_matches[new_counter] = []
+            return original_collect(*args, **kwargs)
+
+        view_instrument_match.collect = collect_with_modification
+        storage.collect()
+
+        self.assertIn(new_counter, storage._instrument_view_instrument_matches)
+
     @patch(
         "opentelemetry.sdk.metrics._internal."
         "metric_reader_storage._ViewInstrumentMatch"