Skip to content
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#5135](https://github.com/open-telemetry/opentelemetry-python/pull/5135))
- ci: wait for tracecontext server readiness instead of a fixed sleep in `scripts/tracecontext-integration-test.sh`
([#5149](https://github.com/open-telemetry/opentelemetry-python/pull/5149))
- Add ability to selectively enable exporting of SDK internal metrics with the `OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED` environment variable.
([#5151](https://github.com/open-telemetry/opentelemetry-python/pull/5151))

## Version 1.41.0/0.62b0 (2026-04-09)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
from __future__ import annotations

from collections import Counter
from contextlib import contextmanager
from contextlib import AbstractContextManager, contextmanager
from dataclasses import dataclass
from time import perf_counter
from typing import TYPE_CHECKING, Iterator
from typing import TYPE_CHECKING, Iterator, Protocol

from opentelemetry.metrics import MeterProvider, get_meter_provider
from opentelemetry.semconv._incubating.attributes.otel_attributes import (
Expand Down Expand Up @@ -56,6 +56,18 @@ class ExportResult:
error_attrs: Attributes = None


class ExporterMetricsT(Protocol):
def export_operation(
self, num_items: int
) -> AbstractContextManager[ExportResult]: ...


class NoOpExporterMetrics:
@contextmanager
def export_operation(self, num_items: int) -> Iterator[ExportResult]:
yield ExportResult()


class ExporterMetrics:
def __init__(
self,
Expand Down Expand Up @@ -85,14 +97,14 @@ def __init__(
elif endpoint.scheme == "http":
port = 80

component_type = (
component_type or OtelComponentTypeValues("unknown_otlp_exporter")
).value
count = _component_counter[component_type]
_component_counter[component_type] = count + 1
component_type_value = (
component_type.value if component_type else "unknown_otlp_exporter"
)
count = _component_counter[component_type_value]
_component_counter[component_type_value] = count + 1
self._standard_attrs: dict[str, AttributeValue] = {
OTEL_COMPONENT_TYPE: component_type,
OTEL_COMPONENT_NAME: f"{component_type}/{count}",
OTEL_COMPONENT_TYPE: component_type_value,
OTEL_COMPONENT_NAME: f"{component_type_value}/{count}",
}
if endpoint.hostname:
self._standard_attrs[SERVER_ADDRESS] = endpoint.hostname
Expand Down Expand Up @@ -131,3 +143,21 @@ def export_operation(self, num_items: int) -> Iterator[ExportResult]:
else exported_attrs
)
self._duration.record(end_time - start_time, duration_attrs)


def create_exporter_metrics(
component_type: OtelComponentTypeValues | None,
signal: Literal["traces", "metrics", "logs"],
endpoint: UrlParseResult,
meter_provider: MeterProvider | None,
enabled: bool,
) -> ExporterMetricsT:
if not enabled:
return NoOpExporterMetrics()

return ExporterMetrics(
component_type,
signal,
endpoint,
meter_provider,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import unittest
from unittest.mock import Mock, patch
from urllib.parse import urlparse

from opentelemetry.exporter.otlp.proto.common._exporter_metrics import (
ExporterMetrics,
NoOpExporterMetrics,
create_exporter_metrics,
)
from opentelemetry.semconv._incubating.attributes.otel_attributes import (
OtelComponentTypeValues,
)


class TestExporterMetrics(unittest.TestCase):
def test_factory_returns_noop_when_disabled(self):
meter_provider = Mock()

with patch(
"opentelemetry.exporter.otlp.proto.common."
"_exporter_metrics.get_meter_provider"
) as get_meter_provider:
metrics = create_exporter_metrics(
OtelComponentTypeValues.OTLP_HTTP_SPAN_EXPORTER,
"traces",
urlparse("http://localhost:4318/v1/traces"),
meter_provider,
False,
)

self.assertIsInstance(metrics, NoOpExporterMetrics)
meter_provider.get_meter.assert_not_called()
get_meter_provider.assert_not_called()

def test_factory_returns_exporter_metrics_when_enabled(self):
meter_provider = Mock()
meter_provider.get_meter.return_value = Mock()

metrics = create_exporter_metrics(
OtelComponentTypeValues.OTLP_HTTP_SPAN_EXPORTER,
"traces",
urlparse("http://localhost:4318/v1/traces"),
meter_provider,
True,
)

self.assertIsInstance(metrics, ExporterMetrics)
meter_provider.get_meter.assert_called_once_with("opentelemetry-sdk")

def test_noop_export_operation_yields_result(self):
metrics = NoOpExporterMetrics()

with metrics.export_operation(1) as result:
result.error = RuntimeError("error")

self.assertIsInstance(result.error, RuntimeError)
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
ssl_channel_credentials,
)
from opentelemetry.exporter.otlp.proto.common._exporter_metrics import (
ExporterMetrics,
create_exporter_metrics,
)
from opentelemetry.exporter.otlp.proto.common._internal import (
_get_resource_data,
Expand Down Expand Up @@ -103,6 +103,10 @@
OTEL_EXPORTER_OTLP_HEADERS,
OTEL_EXPORTER_OTLP_INSECURE,
OTEL_EXPORTER_OTLP_TIMEOUT,
OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED,
)
from opentelemetry.sdk.environment_variables._internal import (
Copy link
Copy Markdown
Contributor

@xrmx xrmx May 4, 2026

Choose a reason for hiding this comment

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

The exporters does not depend on latest sdk version so either we bump the baseline or we add an helper in otlp.proto.common and live with the duplication.
We should probably also write this relationship somewhere in text or in tests against the baseline and not only latest sdk, not in this PR but just thinking out of loud.

Copy link
Copy Markdown
Contributor

@xrmx xrmx May 4, 2026

Choose a reason for hiding this comment

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

Actually it's old exporter with new sdk so something more hard to test. But yeah the point is that if the parse_boolean_environment_variable symbol go away in a future sdk we break the contract.

Copy link
Copy Markdown
Contributor Author

@herin049 herin049 May 4, 2026

Choose a reason for hiding this comment

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

The same issue applies to all of the other imports from the SDK that the exporters use. For example, we use the experimental _OTEL_PYTHON_EXPORTER_OTLP_GRPC_CREDENTIAL_PROVIDER from opentelemetry.sdk.environment_variables, so technically we can never remove _OTEL_PYTHON_EXPORTER_OTLP_GRPC_CREDENTIAL_PROVIDER anymore from the SDK without breaking older versions of the HTTP exporter. While not ideal, I'd be more in favor of pinning the version of the SDK that exporters depend on to avoid potential scenarios like these in the future. Perhaps something to bring up in the SIG to get some other opinions.

parse_boolean_environment_variable,
)
from opentelemetry.sdk.metrics.export import MetricExportResult, MetricsData
from opentelemetry.sdk.resources import Resource as SDKResource
Expand Down Expand Up @@ -387,11 +391,14 @@ def __init__(
self._component_type = component_type
self._signal: Literal["traces", "metrics", "logs"] = signal
self._parsed_url = parsed_url
self._metrics = ExporterMetrics(
self._metrics = create_exporter_metrics(
self._component_type,
signal,
parsed_url,
meter_provider,
parse_boolean_environment_variable(
OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED
),
)

self._initialize_channel_and_stub()
Expand Down Expand Up @@ -549,9 +556,12 @@ def _exporting(self) -> str:
pass

def _set_meter_provider(self, meter_provider: MeterProvider) -> None:
self._metrics = ExporterMetrics(
self._metrics = create_exporter_metrics(
self._component_type,
self._signal,
self._parsed_url,
meter_provider,
parse_boolean_environment_variable(
OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED
),
)
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from opentelemetry.sdk.environment_variables import (
_OTEL_PYTHON_EXPORTER_OTLP_GRPC_CREDENTIAL_PROVIDER,
OTEL_EXPORTER_OTLP_COMPRESSION,
OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED,
)
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
Expand Down Expand Up @@ -387,13 +388,19 @@ def test_otlp_exporter_otlp_compression_envvar(
),
)

@patch.dict(
"os.environ", {OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED: "true"}
)
def test_shutdown(self):
add_TraceServiceServicer_to_server(
TraceServiceServicerWithExportParams(StatusCode.OK),
self.server,
)
exporter = OTLPSpanExporterForTesting(
insecure=True, meter_provider=self.meter_provider
)
self.assertEqual(
self.exporter.export([self.span]), SpanExportResult.SUCCESS
exporter.export([self.span]), SpanExportResult.SUCCESS
)
metrics_data = self.metric_reader.get_metrics_data()
scope_metrics = metrics_data.resource_metrics[0].scope_metrics[0]
Expand All @@ -415,10 +422,10 @@ def test_shutdown(self):
metrics[2].data.data_points[0].attributes
)

self.exporter.shutdown()
exporter.shutdown()
with self.assertLogs(level=WARNING) as warning:
self.assertEqual(
self.exporter.export([self.span]), SpanExportResult.FAILURE
exporter.export([self.span]), SpanExportResult.FAILURE
)
self.assertEqual(
warning.records[0].message,
Expand Down Expand Up @@ -480,6 +487,9 @@ def test_export_over_closed_grpc_channel(self):
system() == "Windows",
"For gRPC + windows there's some added delay in the RPCs which breaks the assertion over amount of time passed.",
)
@patch.dict(
"os.environ", {OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED: "true"}
)
def test_retry_info_is_respected(self):
mock_trace_service = TraceServiceServicerWithExportParams(
StatusCode.UNAVAILABLE,
Expand Down Expand Up @@ -622,7 +632,13 @@ def test_otlp_headers_from_env(self):
(),
)

@patch.dict(
"os.environ", {OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED: "true"}
)
def test_permanent_failure(self):
exporter = OTLPSpanExporterForTesting(
insecure=True, meter_provider=self.meter_provider
)
with self.assertLogs(level=WARNING) as warning:
add_TraceServiceServicer_to_server(
TraceServiceServicerWithExportParams(
Expand All @@ -631,7 +647,7 @@ def test_permanent_failure(self):
self.server,
)
self.assertEqual(
self.exporter.export([self.span]), SpanExportResult.FAILURE
exporter.export([self.span]), SpanExportResult.FAILURE
)
self.assertEqual(
warning.records[-1].message,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from requests.exceptions import ConnectionError

from opentelemetry.exporter.otlp.proto.common._exporter_metrics import (
ExporterMetrics,
create_exporter_metrics,
)
from opentelemetry.exporter.otlp.proto.common._log_encoder import encode_logs
from opentelemetry.exporter.otlp.proto.http import (
Expand Down Expand Up @@ -61,6 +61,10 @@
OTEL_EXPORTER_OTLP_LOGS_HEADERS,
OTEL_EXPORTER_OTLP_LOGS_TIMEOUT,
OTEL_EXPORTER_OTLP_TIMEOUT,
OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED,
)
from opentelemetry.sdk.environment_variables._internal import (
parse_boolean_environment_variable,
)
from opentelemetry.semconv._incubating.attributes.otel_attributes import (
OtelComponentTypeValues,
Expand Down Expand Up @@ -152,11 +156,14 @@ def __init__(
)
self._shutdown = False

self._metrics = ExporterMetrics(
self._metrics = create_exporter_metrics(
OtelComponentTypeValues.OTLP_HTTP_LOG_EXPORTER,
"logs",
urlparse(self._endpoint),
meter_provider,
parse_boolean_environment_variable(
OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED
),
)

def _export(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from typing_extensions import deprecated

from opentelemetry.exporter.otlp.proto.common._exporter_metrics import (
ExporterMetrics,
create_exporter_metrics,
)
from opentelemetry.exporter.otlp.proto.common._internal import (
_get_resource_data,
Expand Down Expand Up @@ -86,6 +86,10 @@
OTEL_EXPORTER_OTLP_METRICS_HEADERS,
OTEL_EXPORTER_OTLP_METRICS_TIMEOUT,
OTEL_EXPORTER_OTLP_TIMEOUT,
OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED,
)
from opentelemetry.sdk.environment_variables._internal import (
parse_boolean_environment_variable,
)
from opentelemetry.sdk.metrics._internal.aggregation import Aggregation
from opentelemetry.sdk.metrics.export import ( # noqa: F401
Expand Down Expand Up @@ -217,11 +221,14 @@ def __init__(
self._max_export_batch_size: int | None = max_export_batch_size
self._shutdown = False

self._metrics = ExporterMetrics(
self._metrics = create_exporter_metrics(
OtelComponentTypeValues.OTLP_HTTP_METRIC_EXPORTER,
"metrics",
urlparse(self._endpoint),
meter_provider,
parse_boolean_environment_variable(
OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED
),
)

def _export(
Expand Down Expand Up @@ -399,11 +406,14 @@ def force_flush(self, timeout_millis: float = 10_000) -> bool:
return True

def set_meter_provider(self, meter_provider: MeterProvider) -> None:
self._metrics = ExporterMetrics(
self._metrics = create_exporter_metrics(
OtelComponentTypeValues.OTLP_HTTP_METRIC_EXPORTER,
"metrics",
urlparse(self._endpoint),
meter_provider,
parse_boolean_environment_variable(
OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED
),
)


Expand Down
Loading
Loading