Skip to content

fix(sdk): handle unmapped aggregations defensively in MetricReaderStorage.collect - #5622

Open
dlowzzxx wants to merge 9 commits into
open-telemetry:mainfrom
dlowzzxx:fix/metrics-test-thread-leak-collector-fallback-5157
Open

dlowzzxx wants to merge 9 commits into
open-telemetry:mainfrom
dlowzzxx:fix/metrics-test-thread-leak-collector-fallback-5157

Conversation

@dlowzzxx

@dlowzzxx dlowzzxx commented Sep 4, 2026

Copy link
Copy Markdown

Description

Related to #5157

Context

In opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py, MetricReaderStorage.collect() iterates through view instrument matches and assigns data depending on the aggregation instance. When an unmapped or custom aggregation was encountered, no branch was taken, leaving data unassigned and resulting in UnboundLocalError: cannot access local variable 'data' where it is not associated with a value when constructing Metric(...).

Changes

  1. MetricReaderStorage: Added an else: fallback in the aggregation isinstance chain in collect(). Deduplicates warnings using self._unsupported_aggregation_warned so the warning is logged only once per (instrument, type(_aggregation)) pair, avoiding log/stderr flooding during periodic export cycles.
  2. Periodic Exporting Metric Reader Tests: Replaced try/finally with self.addCleanup(pmr.shutdown) in test_exporter_temporality_preference and test_exporter_aggregation_preference.
  3. Tests: Added test_collect_skips_unsupported_aggregation to test_metric_reader_storage.py (avoiding module-level mock of _ViewInstrumentMatch by directly populating _instrument_view_instrument_matches) and asserted that unsupported aggregations are skipped and repeat collections do not re-emit the warning.
  4. Changelog Fragment: Added .changelog/5622.fixed.

@dlowzzxx
dlowzzxx requested a review from a team as a code owner September 4, 2026 16:30
@linux-foundation-easycla

linux-foundation-easycla Bot commented Sep 4, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Sep 4, 2026

Copy link
Copy Markdown

Pull request dashboard status

Waiting on the author · refreshed 2026-09-13 22:53 UTC

Respond to 1 review item (e.g. link a commit, explain why not, ask a follow-up):

  • Top-level threads: 1
Status above doesn't look right?
  • Just replied or pushed? Anything around or after the refresh time above may not be picked up yet — give it a few minutes.
  • Should this be with reviewers? Comment /dashboard route:reviewers to route it to them.
  • Anything wrong — including the routing? Report it with what you expected; it helps us improve the dashboard.

@ocelotl

ocelotl commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

please sign the CLA

@ocelotl

ocelotl commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

how does this PR fix #5157?

@dlowzzxx

dlowzzxx commented Sep 4, 2026

Copy link
Copy Markdown
Author

@ocelotl Thank you for reviewing!

Regarding how this PR addresses #5157:

The traceback captured in #5157 shows two symptoms occurring together:

  1. \AssertionError: 2 != 1\ in \TestMetricReaderStorage.test_creates_view_instrument_matches\
  2. \PytestUnhandledThreadExceptionWarning: Exception in thread OtelPeriodicExportingMetricReader: UnboundLocalError: cannot access local variable 'data' where it is not associated with a value\ in \metric_reader_storage.py:219.

These two failures share a single root cause:

  • Tests in \ est_periodic_exporting_metric_reader.py\ spawned \PeriodicExportingMetricReader\ instances whose daemon background ticker threads (_ticker) were not explicitly joined/shut down in test teardown.
  • While \TestMetricReaderStorage\ was executing, a leaked background ticker thread concurrently invoked \collect()\ -> \storage.consume_measurement(). This triggered _ViewInstrumentMatch\ a second time, causing \AssertionError: 2 != 1. Furthermore, because the mock aggregations in that test did not produce a matching aggregation case in \MetricReaderStorage.collect(), the concurrent call referenced an unassigned \data\ variable, raising \UnboundLocalError.

This PR fixes both:

  1. Adds explicit
    eader.shutdown()\ in \ inally\ blocks across \ est_periodic_exporting_metric_reader.py, preventing the background ticker thread leak.
  2. Adds defensive fallback in \MetricReaderStorage.collect()\ so unmapped aggregations do not crash with \UnboundLocalError.

@dlowzzxx

dlowzzxx commented Sep 6, 2026

Copy link
Copy Markdown
Author

/dashboard route:reviewers

@opentelemetry-pr-dashboard

Copy link
Copy Markdown

@dlowzzxx, this pull request was routed to reviewers.

@ocelotl

ocelotl commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Ok, I looked into this, and I think this PR is valuable, but it still does not fix #5157.

You are right that there is a leaked ticker thread, but it does not cause the issue here in the way this PR is intended to fix it:

@patch("...metric_reader_storage._ViewInstrumentMatch") rebinds a module attribute process wide, so any MeterProvider that builds a view instrument match during that window gets a Mock, and every construction anywhere appends to the same MockViewInstrumentMatch.call_args_list that the test is counting. That causes the 2 != 1. The Mock also gets stored, its type does not match the ones we check for, and that is what raises UnboundLocalError on a later tick.

#5157 is 2 separate problems, I am adding their fixes here:

  1. Tests in test_metric_reader_storage.py assert on process-global mock state instead of on the storage under test #5638 / Assert on the storage under test, not on process-global mock state, in test_metric_reader_storage.py #5639, tests asserting on process global mock state
  2. Nothing detects a test that leaks a PeriodicExportingMetricReader ticker thread, and four tests leak one today #5640 / Fail any test that leaks a PeriodicExportingMetricReader ticker thread, and fix the four that do #5641, no detector for leaked ticker threads

I'll comment in this PR diff with the parts of this PR I find valuable.

data_points=data_points,
aggregation_temporality=aggregation_temporality,
)
else:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a genuine bug fix, Aggregation is a public API so we can expect to handle this case.

A user with a custom Aggregation would get a warning with every export, so we probably want to reconsider this particular approach.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: warnings are now deduplicated via self._unsupported_aggregation_warned using (instrument, type(view_instrument_match._aggregation)) as the key, ensuring unmapped or custom aggregations only log a warning once per instrument.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest another approach: take look here.

I think it is better to put the check this PR attempts to introduce there as well. If we use the current approach from this PR the warning shows once and only if the process starts after its logging is set up, if not the warning is lost and the user never realizes their data is gone. Here we can just keep the else/continue.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: moved the unsupported aggregation check to _check_view_instrument_compatibility (returning False so the incompatible view is not applied, matching #5461) and kept the clean else: continue fallback in MetricReaderStorage.collect().

Comment thread .changelog/5622.fixed Outdated
@@ -0,0 +1 @@
`opentelemetry-sdk`: log a warning and skip unmapped aggregations in `MetricReaderStorage.collect` to prevent `UnboundLocalError`, and shut down periodic metric readers in tests to prevent background daemon thread leaks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be updated, test case changes are not something we want to tell users about.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated: simplified the changelog fragment to describe only user-facing SDK behavior (skip unmapped aggregations in MetricReaderStorage.collect and log a warning once per instrument to prevent UnboundLocalError).

@@ -245,11 +245,14 @@ def test_exporter_temporality_preference(self):
},
)
pmr = PeriodicExportingMetricReader(exporter)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider self.addCleanup(pmr.shutdown) as in #5641

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to self.addCleanup(pmr.shutdown) as in #5641.

@@ -258,11 +261,14 @@ def test_exporter_aggregation_preference(self):
},
)
pmr = PeriodicExportingMetricReader(exporter)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to self.addCleanup(pmr.shutdown) as in #5641.

@ocelotl ocelotl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR contains valuable changes but needs refactoring, its changes are also not a fix for the target issue.

@github-project-automation github-project-automation Bot moved this to Reviewed PRs that need fixes in Python PR digest Sep 9, 2026
@dlowzzxx dlowzzxx changed the title fix(metrics): shutdown periodic readers in tests and handle unmapped aggregations (#5157) fix(sdk): handle unmapped aggregations defensively in MetricReaderStorage.collect Sep 9, 2026
@dlowzzxx

dlowzzxx commented Sep 9, 2026

Copy link
Copy Markdown
Author

@ocelotl Thank you for the detailed review and clarifying the root cause for #5157!

I have refactored this PR according to your feedback:

  1. Warning Deduplication (MetricReaderStorage.collect):

    • Added self._unsupported_aggregation_warned: set[tuple[_Instrument, type[_Aggregation]]] = set() in MetricReaderStorage.__init__.
    • In MetricReaderStorage.collect(), the warning is now emitted only once per (instrument, type(view_instrument_match._aggregation)) pair, preventing log/stderr flooding during periodic export cycles.
  2. Test Refactoring (test_metric_reader_storage.py):

    • Removed @patch("opentelemetry.sdk.metrics._internal.metric_reader_storage._ViewInstrumentMatch") to eliminate process-wide class patching.
    • Directly populated storage._instrument_view_instrument_matches with the test matches.
    • Added assertions to verify that repeated calls to storage.collect() do not re-emit the warning.
  3. Cleanup Pattern (test_periodic_exporting_metric_reader.py):

    • Replaced try/finally: pmr.shutdown() with self.addCleanup(pmr.shutdown) in test_exporter_temporality_preference and test_exporter_aggregation_preference.
  4. Changelog Fragment (.changelog/5622.fixed):

    • Simplified to focus strictly on user-facing SDK changes, removing references to test cleanups.
  5. PR Title:

    • Updated title to fix(sdk): handle unmapped aggregations defensively in MetricReaderStorage.collect.

/dashboard route:reviewers

@dlowzzxx

dlowzzxx commented Sep 9, 2026

Copy link
Copy Markdown
Author

@ocelotl All review feedback and inline threads have been addressed and updated:

  1. Warning Deduplication (MetricReaderStorage.collect):

    • Added self._unsupported_aggregation_warned: set[tuple[_Instrument, type[_Aggregation]]] = set() in MetricReaderStorage.__init__.
    • In MetricReaderStorage.collect(), deduplicated warnings using (instrument, type(view_instrument_match._aggregation)) as the key, ensuring unmapped or custom aggregations log a warning at most once per instrument rather than flooding on periodic export cycles.
  2. Test Refactoring (test_metric_reader_storage.py):

    • Removed process-wide @patch("..._ViewInstrumentMatch").
    • Populated storage._instrument_view_instrument_matches directly with test matches.
    • Scoped assertLogs and assertNoLogs to "opentelemetry.sdk.metrics._internal.metric_reader_storage" to prevent ambient thread log pollution.
    • Verified that repeated storage.collect() calls do not re-emit the warning, that internal state is recorded, and that subsequent new instruments with unsupported aggregations still emit a warning once.
  3. Cleanup Pattern (test_periodic_exporting_metric_reader.py):

  4. Changelog Fragment (.changelog/5622.fixed):

    • Simplified to focus strictly on user-facing SDK behavior: `opentelemetry-sdk`: skip unmapped aggregations in `MetricReaderStorage.collect` and log a warning once per instrument to prevent `UnboundLocalError`.
  5. Inline Threads & PR Title:

    • Replied to all inline review threads and updated the PR title.

Latest commit: b232b18

/dashboard route:reviewers

@ocelotl

ocelotl commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

thanks! please resolve the conflicts ✌️ so I can continue reviewing and approving

…y-python into fix/metrics-test-thread-leak-collector-fallback-5157
@dlowzzxx

dlowzzxx commented Sep 9, 2026

Copy link
Copy Markdown
Author

@ocelotl Conflicts with main have been resolved cleanly! All tests pass. Ready for your review and approval ✌️

/dashboard route:reviewers

warn_key = (instrument, type(view_instrument_match._aggregation))
if warn_key not in self._unsupported_aggregation_warned:
self._unsupported_aggregation_warned.add(warn_key)
_logger.warning(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would render something like this

Unsupported aggregation <opentelemetry.sdk.metrics._internal.aggregation._SumAggregation object at 0x79db26bccc20> for instrument <opentelemetry.sdk.metrics._internal.instrument._Counter object at 0x79db26bccad0>

Better to use type(view_instrument_match._aggregation).__name__ and instrument.name.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: updated warning formatting to use ype(view._aggregation).name and instrument.name.

@dlowzzxx

Copy link
Copy Markdown
Author

@ocelotl Thank you for the review and guidance!

I have updated the implementation to align with PR #5461:

  1. View Compatibility Check: Moved the unsupported aggregation check to _check_view_instrument_compatibility. When view._aggregation is an Aggregation instance that is not one of the SDK-supported aggregations (DefaultAggregation, DropAggregation, ExplicitBucketHistogramAggregation, ExponentialBucketHistogramAggregation, LastValueAggregation, SumAggregation), a warning is logged:
    "Unsupported aggregation %s for instrument %s"
    using type(view._aggregation).__name__ and instrument.name, and the method returns False so the incompatible view is not applied.
  2. Defensive Collector Fallback: In MetricReaderStorage.collect(), retained the simple else: continue branch to cleanly guard against UnboundLocalError without duplicate logging during collection. Removed self._unsupported_aggregation_warned.
  3. Tests: Added test_unsupported_aggregation_view_not_applied to assert view rejection and warning logging, and verified test_collect_skips_unsupported_aggregation. All 324 metrics tests pass locally, with clean ruff linting and formatting.

Commit: d7ada04

/dashboard route:reviewers

@dlowzzxx

Copy link
Copy Markdown
Author

Rebased on latest main, updated the _LastValueAggregation constructor arguments following the merge of #5637, and verified all tests pass. Ready for your review!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Reviewed PRs that need fixes

Development

Successfully merging this pull request may close these issues.

2 participants