Skip to content

Commit 3a651c7

Browse files
authored
Fix handling of empty metric collection cycles (#3335)
1 parent 6070a0d commit 3a651c7

10 files changed

+184
-73
lines changed

CHANGELOG.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
- Fix handling of empty metric collection cycles
11+
([#3335](https://github.com/open-telemetry/opentelemetry-python/pull/3335))
1012
- Fix error when no LoggerProvider configured for LoggingHandler
1113
([#3423](https://github.com/open-telemetry/opentelemetry-python/pull/3423))
12-
14+
15+
1316
## Version 1.20.0/0.41b0 (2023-09-04)
1417

1518
- Modify Prometheus exporter to translate non-monotonic Sums into Gauges

opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/_view_instrument_match.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from logging import getLogger
1717
from threading import Lock
1818
from time import time_ns
19-
from typing import Dict, List, Sequence
19+
from typing import Dict, List, Optional, Sequence
2020

2121
from opentelemetry.metrics import Instrument
2222
from opentelemetry.sdk.metrics._internal.aggregation import (
@@ -126,7 +126,7 @@ def collect(
126126
self,
127127
aggregation_temporality: AggregationTemporality,
128128
collection_start_nanos: int,
129-
) -> Sequence[DataPointT]:
129+
) -> Optional[Sequence[DataPointT]]:
130130

131131
data_points: List[DataPointT] = []
132132
with self._lock:
@@ -136,4 +136,8 @@ def collect(
136136
)
137137
if data_point is not None:
138138
data_points.append(data_point)
139-
return data_points
139+
140+
# Returning here None instead of an empty list because the caller
141+
# does not consume a sequence and to be consistent with the rest of
142+
# collect methods that also return None.
143+
return data_points or None

opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py

+9-6
Original file line numberDiff line numberDiff line change
@@ -322,10 +322,14 @@ def collect(self, timeout_millis: float = 10_000) -> None:
322322
)
323323
return
324324

325-
self._receive_metrics(
326-
self._collect(self, timeout_millis=timeout_millis),
327-
timeout_millis=timeout_millis,
328-
)
325+
metrics = self._collect(self, timeout_millis=timeout_millis)
326+
327+
if metrics is not None:
328+
329+
self._receive_metrics(
330+
metrics,
331+
timeout_millis=timeout_millis,
332+
)
329333

330334
@final
331335
def _set_collect_callback(
@@ -515,8 +519,7 @@ def _receive_metrics(
515519
timeout_millis: float = 10_000,
516520
**kwargs,
517521
) -> None:
518-
if metrics_data is None:
519-
return
522+
520523
token = attach(set_value(_SUPPRESS_INSTRUMENTATION_KEY, True))
521524
try:
522525
with self._export_lock:

opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement_consumer.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from abc import ABC, abstractmethod
1818
from threading import Lock
1919
from time import time_ns
20-
from typing import Iterable, List, Mapping
20+
from typing import Iterable, List, Mapping, Optional
2121

2222
# This kind of import is needed to avoid Sphinx errors.
2323
import opentelemetry.sdk.metrics
@@ -51,7 +51,7 @@ def collect(
5151
self,
5252
metric_reader: "opentelemetry.sdk.metrics.MetricReader",
5353
timeout_millis: float = 10_000,
54-
) -> Iterable[Metric]:
54+
) -> Optional[Iterable[Metric]]:
5555
pass
5656

5757

@@ -94,7 +94,7 @@ def collect(
9494
self,
9595
metric_reader: "opentelemetry.sdk.metrics.MetricReader",
9696
timeout_millis: float = 10_000,
97-
) -> Iterable[Metric]:
97+
) -> Optional[Iterable[Metric]]:
9898

9999
with self._lock:
100100
metric_reader_storage = self._reader_storages[metric_reader]
@@ -123,4 +123,6 @@ def collect(
123123
for measurement in measurements:
124124
metric_reader_storage.consume_measurement(measurement)
125125

126-
return self._reader_storages[metric_reader].collect()
126+
result = self._reader_storages[metric_reader].collect()
127+
128+
return result

opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py

+44-41
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from logging import getLogger
1616
from threading import RLock
1717
from time import time_ns
18-
from typing import Dict, List
18+
from typing import Dict, List, Optional
1919

2020
from opentelemetry.metrics import (
2121
Asynchronous,
@@ -119,7 +119,7 @@ def consume_measurement(self, measurement: Measurement) -> None:
119119
):
120120
view_instrument_match.consume_measurement(measurement)
121121

122-
def collect(self) -> MetricsData:
122+
def collect(self) -> Optional[MetricsData]:
123123
# Use a list instead of yielding to prevent a slow reader from holding
124124
# SDK locks
125125

@@ -152,16 +152,21 @@ def collect(self) -> MetricsData:
152152

153153
for view_instrument_match in view_instrument_matches:
154154

155+
data_points = view_instrument_match.collect(
156+
aggregation_temporality, collection_start_nanos
157+
)
158+
159+
if data_points is None:
160+
continue
161+
155162
if isinstance(
156163
# pylint: disable=protected-access
157164
view_instrument_match._aggregation,
158165
_SumAggregation,
159166
):
160167
data = Sum(
161168
aggregation_temporality=aggregation_temporality,
162-
data_points=view_instrument_match.collect(
163-
aggregation_temporality, collection_start_nanos
164-
),
169+
data_points=data_points,
165170
is_monotonic=isinstance(
166171
instrument, (Counter, ObservableCounter)
167172
),
@@ -171,20 +176,14 @@ def collect(self) -> MetricsData:
171176
view_instrument_match._aggregation,
172177
_LastValueAggregation,
173178
):
174-
data = Gauge(
175-
data_points=view_instrument_match.collect(
176-
aggregation_temporality, collection_start_nanos
177-
)
178-
)
179+
data = Gauge(data_points=data_points)
179180
elif isinstance(
180181
# pylint: disable=protected-access
181182
view_instrument_match._aggregation,
182183
_ExplicitBucketHistogramAggregation,
183184
):
184185
data = Histogram(
185-
data_points=view_instrument_match.collect(
186-
aggregation_temporality, collection_start_nanos
187-
),
186+
data_points=data_points,
188187
aggregation_temporality=aggregation_temporality,
189188
)
190189
elif isinstance(
@@ -200,9 +199,7 @@ def collect(self) -> MetricsData:
200199
_ExponentialBucketHistogramAggregation,
201200
):
202201
data = ExponentialHistogram(
203-
data_points=view_instrument_match.collect(
204-
aggregation_temporality, collection_start_nanos
205-
),
202+
data_points=data_points,
206203
aggregation_temporality=aggregation_temporality,
207204
)
208205

@@ -216,32 +213,38 @@ def collect(self) -> MetricsData:
216213
)
217214
)
218215

219-
if instrument.instrumentation_scope not in (
220-
instrumentation_scope_scope_metrics
221-
):
222-
instrumentation_scope_scope_metrics[
223-
instrument.instrumentation_scope
224-
] = ScopeMetrics(
225-
scope=instrument.instrumentation_scope,
226-
metrics=metrics,
227-
schema_url=instrument.instrumentation_scope.schema_url,
228-
)
229-
else:
230-
instrumentation_scope_scope_metrics[
231-
instrument.instrumentation_scope
232-
].metrics.extend(metrics)
233-
234-
return MetricsData(
235-
resource_metrics=[
236-
ResourceMetrics(
237-
resource=self._sdk_config.resource,
238-
scope_metrics=list(
239-
instrumentation_scope_scope_metrics.values()
240-
),
241-
schema_url=self._sdk_config.resource.schema_url,
216+
if metrics:
217+
218+
if instrument.instrumentation_scope not in (
219+
instrumentation_scope_scope_metrics
220+
):
221+
instrumentation_scope_scope_metrics[
222+
instrument.instrumentation_scope
223+
] = ScopeMetrics(
224+
scope=instrument.instrumentation_scope,
225+
metrics=metrics,
226+
schema_url=instrument.instrumentation_scope.schema_url,
227+
)
228+
else:
229+
instrumentation_scope_scope_metrics[
230+
instrument.instrumentation_scope
231+
].metrics.extend(metrics)
232+
233+
if instrumentation_scope_scope_metrics:
234+
235+
return MetricsData(
236+
resource_metrics=[
237+
ResourceMetrics(
238+
resource=self._sdk_config.resource,
239+
scope_metrics=list(
240+
instrumentation_scope_scope_metrics.values()
241+
),
242+
schema_url=self._sdk_config.resource.schema_url,
243+
)
244+
]
242245
)
243-
]
244-
)
246+
247+
return None
245248

246249
def _handle_view_instrument_match(
247250
self,

opentelemetry-sdk/tests/metrics/integration_test/test_console_exporter.py

+16
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,19 @@ def test_console_exporter(self):
7272

7373
self.assertEqual(metrics["attributes"], {"a": "b"})
7474
self.assertEqual(metrics["value"], 1)
75+
76+
def test_console_exporter_no_export(self):
77+
78+
output = StringIO()
79+
exporter = ConsoleMetricExporter(out=output)
80+
reader = PeriodicExportingMetricReader(
81+
exporter, export_interval_millis=100
82+
)
83+
provider = MeterProvider(metric_readers=[reader])
84+
provider.shutdown()
85+
86+
output.seek(0)
87+
actual = "".join(output.readlines())
88+
expected = ""
89+
90+
self.assertEqual(actual, expected)

opentelemetry-sdk/tests/metrics/integration_test/test_disable_default_views.py

+1-9
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,7 @@ def test_disable_default_views(self):
3131
counter.add(10, {"label": "value1"})
3232
counter.add(10, {"label": "value2"})
3333
counter.add(10, {"label": "value3"})
34-
self.assertEqual(
35-
(
36-
reader.get_metrics_data()
37-
.resource_metrics[0]
38-
.scope_metrics[0]
39-
.metrics
40-
),
41-
[],
42-
)
34+
self.assertIsNone(reader.get_metrics_data())
4335

4436
def test_disable_default_views_add_custom(self):
4537
reader = InMemoryMetricReader()

opentelemetry-sdk/tests/metrics/integration_test/test_exporter_concurrency.py

+14
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ class TestExporterConcurrency(ConcurrencyTestBase):
7474
> be called again only after the current call returns.
7575
7676
https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/sdk.md#exportbatch
77+
78+
This test also tests that a thread that calls the a
79+
``MetricReader.collect`` method using an asynchronous instrument is able
80+
to perform two actions in the same thread lock space (without it being
81+
interrupted by another thread):
82+
83+
1. Consume the measurement produced by the callback associated to the
84+
asynchronous instrument.
85+
2. Export the measurement mentioned in the step above.
7786
"""
7887

7988
def test_exporter_not_called_concurrently(self):
@@ -84,7 +93,11 @@ def test_exporter_not_called_concurrently(self):
8493
)
8594
meter_provider = MeterProvider(metric_readers=[reader])
8695

96+
counter_cb_counter = 0
97+
8798
def counter_cb(options: CallbackOptions):
99+
nonlocal counter_cb_counter
100+
counter_cb_counter += 1
88101
yield Observation(2)
89102

90103
meter_provider.get_meter(__name__).create_observable_counter(
@@ -97,6 +110,7 @@ def test_many_threads():
97110

98111
self.run_with_many_threads(test_many_threads, num_threads=100)
99112

113+
self.assertEqual(counter_cb_counter, 100)
100114
# no thread should be in export() now
101115
self.assertEqual(exporter.count_in_export, 0)
102116
# should be one call for each thread

0 commit comments

Comments
 (0)