From 158aeb0dd5e1736fd2bbd49612df95f7aa56894a Mon Sep 17 00:00:00 2001 From: Kc Balusu Date: Mon, 29 Jun 2026 12:27:13 -0400 Subject: [PATCH 1/2] opentelemetry-exporter-otlp-proto-http: fix metric self-observability over-count on batch split When max_export_batch_size splits an export, each split was recording the whole batch's data-point count in the exporter's self-observability metrics (otel.sdk.exporter.metric_data_point.*), over-counting exported/inflight data points. Each split now records its own data-point count. --- .changelog/5370.fixed | 1 + .../proto/http/metric_exporter/__init__.py | 14 +++++- .../metrics/test_otlp_metrics_exporter.py | 50 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 .changelog/5370.fixed diff --git a/.changelog/5370.fixed b/.changelog/5370.fixed new file mode 100644 index 00000000000..e727213a581 --- /dev/null +++ b/.changelog/5370.fixed @@ -0,0 +1 @@ +`opentelemetry-exporter-otlp-proto-http`: fix the OTLP HTTP metric exporter self-observability metrics over-counting data points when `max_export_batch_size` splits a batch; each split now reports its own data-point count instead of the whole-batch count. diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py index eb1e69cfe4f..f69b081293c 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py @@ -367,7 +367,7 @@ def export( export_result = self._export_with_retries( split_metrics_data, deadline_sec, - num_items, + _count_data_points(split_metrics_data), ) if export_result != MetricExportResult.SUCCESS: return MetricExportResult.FAILURE @@ -404,6 +404,18 @@ def set_meter_provider(self, meter_provider: MeterProvider) -> None: ) +def _count_data_points(export_request: ExportMetricsServiceRequest) -> int: + """Count the number of data points in an encoded metrics export request.""" + count = 0 + for resource_metrics in export_request.resource_metrics: + for scope_metrics in resource_metrics.scope_metrics: + for metric in scope_metrics.metrics: + field_name = metric.WhichOneof("data") + if field_name: + count += len(getattr(metric, field_name).data_points) + return count + + def _split_metrics_data( metrics_data: ExportMetricsServiceRequest, max_export_batch_size: int | None = None, diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py index 84a11e8ae90..eb9839d07c9 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py @@ -128,6 +128,56 @@ def setUp(self): ), } + @patch.dict( + "os.environ", {OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED: "true"} + ) + @patch.object(Session, "post") + def test_split_export_records_per_split_data_point_count(self, mock_post): + # When a batch is split, each split must record its own data-point + # count in the self-observability metric, not the whole-batch count. + resp = Response() + resp.status_code = 200 + mock_post.return_value = resp + metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource( + attributes={"a": 1}, schema_url="resource_schema_url" + ), + scope_metrics=[ + ScopeMetrics( + scope=SDKInstrumentationScope( + name="name", version="version" + ), + metrics=[ + _generate_sum("s1", 1), + _generate_sum("s2", 2), + _generate_sum("s3", 3), + ], + schema_url="scope_schema_url", + ) + ], + schema_url="resource_schema_url", + ) + ] + ) + exporter = OTLPMetricExporter( + max_export_batch_size=1, meter_provider=self.meter_provider + ) + self.assertEqual( + exporter.export(metrics_data), MetricExportResult.SUCCESS + ) + self.assertEqual(mock_post.call_count, 3) + internal = self.metric_reader.get_metrics_data() + scope_metrics = internal.resource_metrics[0].scope_metrics[0] + exported = next( + metric + for metric in scope_metrics.metrics + if metric.name == "otel.sdk.exporter.metric_data_point.exported" + ) + total = sum(dp.value for dp in exported.data.data_points) + self.assertEqual(total, 3) + def test_constructor_default(self): exporter = OTLPMetricExporter() From 221517cb27f03abddc8914234b589b10160921c4 Mon Sep 17 00:00:00 2001 From: Kc Balusu Date: Mon, 29 Jun 2026 21:35:57 -0400 Subject: [PATCH 2/2] opentelemetry-exporter-otlp-proto-http: dedupe metric data-point counting Use the single _count_data_points helper for both the unsplit request and each split batch, removing the duplicate count loop in export(). --- .../otlp/proto/http/metric_exporter/__init__.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py index f69b081293c..6cf13deae17 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py @@ -343,19 +343,15 @@ def export( _logger.warning("Exporter already shutdown, ignoring batch") return MetricExportResult.FAILURE - num_items = 0 - for resource_metrics in metrics_data.resource_metrics: - for scope_metrics in resource_metrics.scope_metrics: - for metric in scope_metrics.metrics: - num_items += len(metric.data.data_points) - export_request = encode_metrics(metrics_data) deadline_sec = time() + self._timeout # If no batch size configured, export as single batch with retries as configured if self._max_export_batch_size is None: return self._export_with_retries( - export_request, deadline_sec, num_items + export_request, + deadline_sec, + _count_data_points(export_request), ) # Else, export in batches of configured size