From 1008906a7ca832a15e0b8cdcb60d141ff6c641ae Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Tue, 11 Nov 2025 02:47:38 -0600 Subject: [PATCH 1/6] Implement partial success logging. --- .../otlp/proto/grpc/_log_exporter/__init__.py | 6 ++++++ .../exporter/otlp/proto/grpc/exporter.py | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py index 646aa56cbd..feb504180e 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py @@ -12,6 +12,7 @@ # limitations under the License. from os import environ +import sys from typing import Dict, Literal, Optional, Sequence, Tuple, Union from typing import Sequence as TypingSequence @@ -109,6 +110,11 @@ def _translate_data( ) -> ExportLogsServiceRequest: return encode_logs(data) + def _log_partial_success(self, partial_success): + # Override that skips the "logging" module due to the possibility + # of circular logic (logging -> OTLP logs export). + sys.stderr.write(f"Partial success:\n{partial_success}\n") + def export( # type: ignore [reportIncompatibleMethodOverride] self, batch: Sequence[LogData], diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py index 47e99f7229..04ed163551 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py @@ -93,6 +93,7 @@ OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_INSECURE, OTEL_EXPORTER_OTLP_TIMEOUT, + OTEL_LOG_LEVEL ) from opentelemetry.sdk.metrics.export import MetricExportResult, MetricsData from opentelemetry.sdk.resources import Resource as SDKResource @@ -257,6 +258,11 @@ def _get_credentials( return ssl_channel_credentials() +def _should_log_partial_responses(): + otel_log_level = environ.get(OTEL_LOG_LEVEL, "off").lower() + return otel_log_level in ["verbose", "debug", "info"] + + # pylint: disable=no-member class OTLPExporterMixin( ABC, Generic[SDKDataT, ExportServiceRequestT, ExportResultT, ExportStubT] @@ -293,6 +299,7 @@ def __init__( self._endpoint = endpoint or environ.get( OTEL_EXPORTER_OTLP_ENDPOINT, "http://localhost:4317" ) + self._partial_response_logging_enabled = _should_log_partial_responses() parsed_url = urlparse(self._endpoint) @@ -374,6 +381,13 @@ def _translate_data( ) -> ExportServiceRequestT: pass + def _log_partial_success(self, partial_success): + logger.info(f"Partial success:\n{partial_success}") + + def _process_response(self, response): + if self._partial_response_logging_enabled and response.HasField("partial_success"): + self._log_partial_success(response.partial_success) + def _export( self, data: SDKDataT, @@ -388,11 +402,12 @@ def _export( deadline_sec = time() + self._timeout for retry_num in range(_MAX_RETRYS): try: - self._client.Export( + response = self._client.Export( request=self._translate_data(data), metadata=self._headers, timeout=deadline_sec - time(), ) + self._process_response(response) return self._result.SUCCESS # type: ignore [reportReturnType] except RpcError as error: retry_info_bin = dict(error.trailing_metadata()).get( # type: ignore [reportAttributeAccessIssue] From 72b2325e33f74f05b28171cd07dbfabb8734744e Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Tue, 11 Nov 2025 03:08:39 -0600 Subject: [PATCH 2/6] Add tests. --- .../tests/logs/test_otlp_logs_exporter.py | 26 ++++ .../tests/test_otlp_exporter_mixin.py | 113 ++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py index a8e015e821..d7c1146cdd 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py @@ -14,6 +14,8 @@ # pylint: disable=too-many-lines +from io import StringIO +import sys import time from os.path import dirname from unittest import TestCase @@ -28,7 +30,9 @@ OTLPLogExporter, ) from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import ( + ExportLogsPartialSuccess, ExportLogsServiceRequest, + ExportLogsServiceResponse, ) from opentelemetry.proto.common.v1.common_pb2 import AnyValue, KeyValue from opentelemetry.proto.common.v1.common_pb2 import ( @@ -48,6 +52,7 @@ OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_HEADERS, OTEL_EXPORTER_OTLP_LOGS_TIMEOUT, + OTEL_LOG_LEVEL, ) from opentelemetry.sdk.resources import Resource as SDKResource from opentelemetry.sdk.util.instrumentation import InstrumentationScope @@ -316,6 +321,27 @@ def export_log_and_deserialize(self, log_data): ) return log_records + @patch.dict("os.environ", {OTEL_LOG_LEVEL: "info"}) + @patch("sys.stderr", new_callable=StringIO) + def test_partial_success_recorded_directly_to_stderr(self, mock_stderr): + # pylint: disable=protected-access + exporter = OTLPLogExporter() + exporter._client = Mock() + exporter._client.Export.return_value = ( + ExportLogsServiceResponse( + partial_success=ExportLogsPartialSuccess( + rejected_log_records=1, + error_message="Log record dropped", + ) + ) + ) + + exporter.export([self.log_data_1]) + + self.assertIn("Partial success:\n", mock_stderr.getvalue()) + self.assertIn("rejected_log_records: 1\n", mock_stderr.getvalue()) + self.assertIn('error_message: "Log record dropped"\n', mock_stderr.getvalue()) + def test_exported_log_without_trace_id(self): log_records = self.export_log_and_deserialize(self.log_data_4) if log_records: diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py index d64a601bbb..fd2829a5ff 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py @@ -40,6 +40,7 @@ ) from opentelemetry.exporter.otlp.proto.grpc.version import __version__ from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ( + ExportTracePartialSuccess, ExportTraceServiceRequest, ExportTraceServiceResponse, ) @@ -51,6 +52,7 @@ from opentelemetry.sdk.environment_variables import ( _OTEL_PYTHON_EXPORTER_OTLP_GRPC_CREDENTIAL_PROVIDER, OTEL_EXPORTER_OTLP_COMPRESSION, + OTEL_LOG_LEVEL, ) from opentelemetry.sdk.trace import ReadableSpan, _Span from opentelemetry.sdk.trace.export import ( @@ -534,3 +536,114 @@ def test_permanent_failure(self): warning.records[-1].message, "Failed to export traces to localhost:4317, error code: StatusCode.ALREADY_EXISTS", ) + + @patch.dict("os.environ", {}, clear=True) + @patch("logging.Logger.info") + def test_does_not_record_partial_success_if_log_level_unset( + self, mock_logger_info + ): + exporter = OTLPSpanExporterForTesting(insecure=True) + # pylint: disable=protected-access + exporter._client = Mock() + exporter._client.Export.return_value = ExportTraceServiceResponse( + partial_success=ExportTracePartialSuccess( + rejected_spans=1, + error_message="Span dropped", + ) + ) + exporter.export([self.span]) + mock_logger_info.assert_not_called() + + @patch.dict("os.environ", {OTEL_LOG_LEVEL: "off"}) + @patch("logging.Logger.info") + def test_does_not_record_partial_success_if_log_level_off( + self, mock_logger_info + ): + exporter = OTLPSpanExporterForTesting(insecure=True) + # pylint: disable=protected-access + exporter._client = Mock() + exporter._client.Export.return_value = ExportTraceServiceResponse( + partial_success=ExportTracePartialSuccess( + rejected_spans=1, + error_message="Span dropped", + ) + ) + exporter.export([self.span]) + mock_logger_info.assert_not_called() + + @patch.dict("os.environ", {OTEL_LOG_LEVEL: "error"}) + @patch("logging.Logger.info") + def test_does_not_record_partial_success_if_log_level_error( + self, mock_logger_info + ): + exporter = OTLPSpanExporterForTesting(insecure=True) + # pylint: disable=protected-access + exporter._client = Mock() + exporter._client.Export.return_value = ExportTraceServiceResponse( + partial_success=ExportTracePartialSuccess( + rejected_spans=1, + error_message="Span dropped", + ) + ) + exporter.export([self.span]) + mock_logger_info.assert_not_called() + + @patch.dict("os.environ", {OTEL_LOG_LEVEL: "verbose"}) + @patch("logging.Logger.info") + def test_records_partial_success_if_log_level_verbose( + self, mock_logger_info + ): + exporter = OTLPSpanExporterForTesting(insecure=True) + # pylint: disable=protected-access + exporter._client = Mock() + partial_success = ExportTracePartialSuccess( + rejected_spans=1, + error_message="Span dropped", + ) + exporter._client.Export.return_value = ExportTraceServiceResponse( + partial_success=partial_success + ) + exporter.export([self.span]) + mock_logger_info.assert_called_once_with( + f"Partial success:\n{partial_success}" + ) + + @patch.dict("os.environ", {OTEL_LOG_LEVEL: "debug"}) + @patch("logging.Logger.info") + def test_records_partial_success_if_log_level_debug( + self, mock_logger_info + ): + exporter = OTLPSpanExporterForTesting(insecure=True) + # pylint: disable=protected-access + exporter._client = Mock() + partial_success = ExportTracePartialSuccess( + rejected_spans=1, + error_message="Span dropped", + ) + exporter._client.Export.return_value = ExportTraceServiceResponse( + partial_success=partial_success + ) + exporter.export([self.span]) + mock_logger_info.assert_called_once_with( + f"Partial success:\n{partial_success}" + ) + + @patch.dict("os.environ", {OTEL_LOG_LEVEL: "info"}) + @patch("logging.Logger.info") + def test_records_partial_success_if_log_level_info( + self, mock_logger_info + ): + exporter = OTLPSpanExporterForTesting(insecure=True) + # pylint: disable=protected-access + exporter._client = Mock() + partial_success = ExportTracePartialSuccess( + rejected_spans=1, + error_message="Span dropped", + ) + exporter._client.Export.return_value = ExportTraceServiceResponse( + partial_success=partial_success + ) + exporter.export([self.span]) + mock_logger_info.assert_called_once_with( + f"Partial success:\n{partial_success}" + ) \ No newline at end of file From e00865815e3e401b967cee3483a69fdf9fd8fb95 Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Tue, 11 Nov 2025 14:35:57 -0600 Subject: [PATCH 3/6] Fix: OTEL_LOG_LEVEL default to info for partial success logging. --- CHANGELOG.md | 2 ++ .../src/opentelemetry/exporter/otlp/proto/grpc/exporter.py | 2 +- .../src/opentelemetry/sdk/environment_variables/__init__.py | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e33330321..c2a782e175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- OTLP exporters now log partial success responses when `OTEL_LOG_LEVEL` is set to `info`, `debug`, or `verbose`. + ([#4805](https://github.com/open-telemetry/opentelemetry-python/pull/4805)) - docs: Added sqlcommenter example ([#4734](https://github.com/open-telemetry/opentelemetry-python/pull/4734)) - build: bump ruff to 0.14.1 diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py index 04ed163551..a62f98e1a1 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py @@ -259,7 +259,7 @@ def _get_credentials( def _should_log_partial_responses(): - otel_log_level = environ.get(OTEL_LOG_LEVEL, "off").lower() + otel_log_level = environ.get(OTEL_LOG_LEVEL, "info").lower() return otel_log_level in ["verbose", "debug", "info"] diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 5baf5fcd55..7c841ae94d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -50,7 +50,8 @@ """ .. envvar:: OTEL_LOG_LEVEL -The :envvar:`OTEL_LOG_LEVEL` environment variable sets the log level used by the SDK logger +The :envvar:`OTEL_LOG_LEVEL` environment variable sets the log level used by the SDK logger. +OTLP exporters will log partial success responses when this is set to `info`, `debug`, or `verbose`. Default: "info" """ From d40ab67aecc77d39ee5f5ebf7d5a1bf3431aa5f2 Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Tue, 11 Nov 2025 23:51:21 -0600 Subject: [PATCH 4/6] Fixes partial success logging to apply at only 'verbose' and 'debug' levels to avoid changing default behaviors. Apply test and formatting fixes. --- CHANGELOG.md | 2 +- .../otlp/proto/grpc/_log_exporter/__init__.py | 4 +- .../exporter/otlp/proto/grpc/exporter.py | 14 +++--- .../tests/logs/test_otlp_logs_exporter.py | 19 ++++---- .../tests/test_otlp_exporter_mixin.py | 44 +++++++++---------- .../sdk/environment_variables/__init__.py | 2 +- 6 files changed, 43 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a782e175..b67e019845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- OTLP exporters now log partial success responses when `OTEL_LOG_LEVEL` is set to `info`, `debug`, or `verbose`. +- OTLP exporters now log partial success responses at `debug` level when `OTEL_LOG_LEVEL` is set to `debug` or `verbose`. ([#4805](https://github.com/open-telemetry/opentelemetry-python/pull/4805)) - docs: Added sqlcommenter example ([#4734](https://github.com/open-telemetry/opentelemetry-python/pull/4734)) diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py index feb504180e..65fc8eebc6 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py @@ -11,8 +11,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from os import environ import sys +from os import environ from typing import Dict, Literal, Optional, Sequence, Tuple, Union from typing import Sequence as TypingSequence @@ -113,7 +113,7 @@ def _translate_data( def _log_partial_success(self, partial_success): # Override that skips the "logging" module due to the possibility # of circular logic (logging -> OTLP logs export). - sys.stderr.write(f"Partial success:\n{partial_success}\n") + sys.stderr.write(f"Partial success:\n{partial_success}\n") def export( # type: ignore [reportIncompatibleMethodOverride] self, diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py index a62f98e1a1..c47170d8c1 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py @@ -93,7 +93,7 @@ OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_INSECURE, OTEL_EXPORTER_OTLP_TIMEOUT, - OTEL_LOG_LEVEL + OTEL_LOG_LEVEL, ) from opentelemetry.sdk.metrics.export import MetricExportResult, MetricsData from opentelemetry.sdk.resources import Resource as SDKResource @@ -260,7 +260,7 @@ def _get_credentials( def _should_log_partial_responses(): otel_log_level = environ.get(OTEL_LOG_LEVEL, "info").lower() - return otel_log_level in ["verbose", "debug", "info"] + return otel_log_level in ["verbose", "debug"] # pylint: disable=no-member @@ -299,7 +299,9 @@ def __init__( self._endpoint = endpoint or environ.get( OTEL_EXPORTER_OTLP_ENDPOINT, "http://localhost:4317" ) - self._partial_response_logging_enabled = _should_log_partial_responses() + self._partial_response_logging_enabled = ( + _should_log_partial_responses() + ) parsed_url = urlparse(self._endpoint) @@ -382,10 +384,12 @@ def _translate_data( pass def _log_partial_success(self, partial_success): - logger.info(f"Partial success:\n{partial_success}") + logger.debug("Partial success:\n%s", partial_success) def _process_response(self, response): - if self._partial_response_logging_enabled and response.HasField("partial_success"): + if self._partial_response_logging_enabled and response.HasField( + "partial_success" + ): self._log_partial_success(response.partial_success) def _export( diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py index d7c1146cdd..18698a2d77 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py @@ -14,9 +14,8 @@ # pylint: disable=too-many-lines -from io import StringIO -import sys import time +from io import StringIO from os.path import dirname from unittest import TestCase from unittest.mock import Mock, patch @@ -321,18 +320,16 @@ def export_log_and_deserialize(self, log_data): ) return log_records - @patch.dict("os.environ", {OTEL_LOG_LEVEL: "info"}) + @patch.dict("os.environ", {OTEL_LOG_LEVEL: "debug"}) @patch("sys.stderr", new_callable=StringIO) def test_partial_success_recorded_directly_to_stderr(self, mock_stderr): # pylint: disable=protected-access exporter = OTLPLogExporter() exporter._client = Mock() - exporter._client.Export.return_value = ( - ExportLogsServiceResponse( - partial_success=ExportLogsPartialSuccess( - rejected_log_records=1, - error_message="Log record dropped", - ) + exporter._client.Export.return_value = ExportLogsServiceResponse( + partial_success=ExportLogsPartialSuccess( + rejected_log_records=1, + error_message="Log record dropped", ) ) @@ -340,7 +337,9 @@ def test_partial_success_recorded_directly_to_stderr(self, mock_stderr): self.assertIn("Partial success:\n", mock_stderr.getvalue()) self.assertIn("rejected_log_records: 1\n", mock_stderr.getvalue()) - self.assertIn('error_message: "Log record dropped"\n', mock_stderr.getvalue()) + self.assertIn( + 'error_message: "Log record dropped"\n', mock_stderr.getvalue() + ) def test_exported_log_without_trace_id(self): log_records = self.export_log_and_deserialize(self.log_data_4) diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py index fd2829a5ff..50f071a84a 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py @@ -538,9 +538,9 @@ def test_permanent_failure(self): ) @patch.dict("os.environ", {}, clear=True) - @patch("logging.Logger.info") + @patch("logging.Logger.debug") def test_does_not_record_partial_success_if_log_level_unset( - self, mock_logger_info + self, mock_logger_debug ): exporter = OTLPSpanExporterForTesting(insecure=True) # pylint: disable=protected-access @@ -552,12 +552,12 @@ def test_does_not_record_partial_success_if_log_level_unset( ) ) exporter.export([self.span]) - mock_logger_info.assert_not_called() + mock_logger_debug.assert_not_called() @patch.dict("os.environ", {OTEL_LOG_LEVEL: "off"}) - @patch("logging.Logger.info") + @patch("logging.Logger.debug") def test_does_not_record_partial_success_if_log_level_off( - self, mock_logger_info + self, mock_logger_debug ): exporter = OTLPSpanExporterForTesting(insecure=True) # pylint: disable=protected-access @@ -569,12 +569,12 @@ def test_does_not_record_partial_success_if_log_level_off( ) ) exporter.export([self.span]) - mock_logger_info.assert_not_called() + mock_logger_debug.assert_not_called() @patch.dict("os.environ", {OTEL_LOG_LEVEL: "error"}) - @patch("logging.Logger.info") + @patch("logging.Logger.debug") def test_does_not_record_partial_success_if_log_level_error( - self, mock_logger_info + self, mock_logger_debug ): exporter = OTLPSpanExporterForTesting(insecure=True) # pylint: disable=protected-access @@ -586,12 +586,12 @@ def test_does_not_record_partial_success_if_log_level_error( ) ) exporter.export([self.span]) - mock_logger_info.assert_not_called() + mock_logger_debug.assert_not_called() @patch.dict("os.environ", {OTEL_LOG_LEVEL: "verbose"}) - @patch("logging.Logger.info") + @patch("logging.Logger.debug") def test_records_partial_success_if_log_level_verbose( - self, mock_logger_info + self, mock_logger_debug ): exporter = OTLPSpanExporterForTesting(insecure=True) # pylint: disable=protected-access @@ -604,14 +604,14 @@ def test_records_partial_success_if_log_level_verbose( partial_success=partial_success ) exporter.export([self.span]) - mock_logger_info.assert_called_once_with( - f"Partial success:\n{partial_success}" + mock_logger_debug.assert_called_once_with( + "Partial success:\n%s", partial_success ) @patch.dict("os.environ", {OTEL_LOG_LEVEL: "debug"}) - @patch("logging.Logger.info") + @patch("logging.Logger.debug") def test_records_partial_success_if_log_level_debug( - self, mock_logger_info + self, mock_logger_debug ): exporter = OTLPSpanExporterForTesting(insecure=True) # pylint: disable=protected-access @@ -624,14 +624,14 @@ def test_records_partial_success_if_log_level_debug( partial_success=partial_success ) exporter.export([self.span]) - mock_logger_info.assert_called_once_with( - f"Partial success:\n{partial_success}" + mock_logger_debug.assert_called_once_with( + "Partial success:\n%s", partial_success ) @patch.dict("os.environ", {OTEL_LOG_LEVEL: "info"}) - @patch("logging.Logger.info") - def test_records_partial_success_if_log_level_info( - self, mock_logger_info + @patch("logging.Logger.debug") + def test_does_not_record_partial_success_if_log_level_info( + self, mock_logger_debug ): exporter = OTLPSpanExporterForTesting(insecure=True) # pylint: disable=protected-access @@ -644,6 +644,4 @@ def test_records_partial_success_if_log_level_info( partial_success=partial_success ) exporter.export([self.span]) - mock_logger_info.assert_called_once_with( - f"Partial success:\n{partial_success}" - ) \ No newline at end of file + mock_logger_debug.assert_not_called() diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 7c841ae94d..087cc4b819 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -51,7 +51,7 @@ .. envvar:: OTEL_LOG_LEVEL The :envvar:`OTEL_LOG_LEVEL` environment variable sets the log level used by the SDK logger. -OTLP exporters will log partial success responses when this is set to `info`, `debug`, or `verbose`. +OTLP exporters will log partial success responses when this is set to `debug` or `verbose`. Default: "info" """ From 5725ccdce4a2269262a8da3d36b6aeabc69e9dde Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Wed, 12 Nov 2025 00:10:03 -0600 Subject: [PATCH 5/6] Simplify tests to address pylint issue and reformat with ruff. --- .../tests/test_otlp_exporter_mixin.py | 157 +++++++----------- 1 file changed, 56 insertions(+), 101 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py index 50f071a84a..eb7d867532 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py @@ -537,111 +537,66 @@ def test_permanent_failure(self): "Failed to export traces to localhost:4317, error code: StatusCode.ALREADY_EXISTS", ) - @patch.dict("os.environ", {}, clear=True) @patch("logging.Logger.debug") - def test_does_not_record_partial_success_if_log_level_unset( + def test_records_partial_success_if_log_level_enabled( self, mock_logger_debug ): - exporter = OTLPSpanExporterForTesting(insecure=True) - # pylint: disable=protected-access - exporter._client = Mock() - exporter._client.Export.return_value = ExportTraceServiceResponse( - partial_success=ExportTracePartialSuccess( - rejected_spans=1, - error_message="Span dropped", - ) - ) - exporter.export([self.span]) - mock_logger_debug.assert_not_called() - - @patch.dict("os.environ", {OTEL_LOG_LEVEL: "off"}) - @patch("logging.Logger.debug") - def test_does_not_record_partial_success_if_log_level_off( - self, mock_logger_debug - ): - exporter = OTLPSpanExporterForTesting(insecure=True) - # pylint: disable=protected-access - exporter._client = Mock() - exporter._client.Export.return_value = ExportTraceServiceResponse( - partial_success=ExportTracePartialSuccess( - rejected_spans=1, - error_message="Span dropped", - ) - ) - exporter.export([self.span]) - mock_logger_debug.assert_not_called() - - @patch.dict("os.environ", {OTEL_LOG_LEVEL: "error"}) - @patch("logging.Logger.debug") - def test_does_not_record_partial_success_if_log_level_error( - self, mock_logger_debug - ): - exporter = OTLPSpanExporterForTesting(insecure=True) - # pylint: disable=protected-access - exporter._client = Mock() - exporter._client.Export.return_value = ExportTraceServiceResponse( - partial_success=ExportTracePartialSuccess( - rejected_spans=1, - error_message="Span dropped", - ) - ) - exporter.export([self.span]) - mock_logger_debug.assert_not_called() - - @patch.dict("os.environ", {OTEL_LOG_LEVEL: "verbose"}) - @patch("logging.Logger.debug") - def test_records_partial_success_if_log_level_verbose( - self, mock_logger_debug - ): - exporter = OTLPSpanExporterForTesting(insecure=True) - # pylint: disable=protected-access - exporter._client = Mock() - partial_success = ExportTracePartialSuccess( - rejected_spans=1, - error_message="Span dropped", - ) - exporter._client.Export.return_value = ExportTraceServiceResponse( - partial_success=partial_success - ) - exporter.export([self.span]) - mock_logger_debug.assert_called_once_with( - "Partial success:\n%s", partial_success - ) - - @patch.dict("os.environ", {OTEL_LOG_LEVEL: "debug"}) - @patch("logging.Logger.debug") - def test_records_partial_success_if_log_level_debug( - self, mock_logger_debug - ): - exporter = OTLPSpanExporterForTesting(insecure=True) - # pylint: disable=protected-access - exporter._client = Mock() - partial_success = ExportTracePartialSuccess( - rejected_spans=1, - error_message="Span dropped", - ) - exporter._client.Export.return_value = ExportTraceServiceResponse( - partial_success=partial_success - ) - exporter.export([self.span]) - mock_logger_debug.assert_called_once_with( - "Partial success:\n%s", partial_success - ) + test_cases = ["verbose", "debug"] + + for log_level_value in test_cases: + with self.subTest(name=f"log_level_{log_level_value}"): + with patch.dict( + "os.environ", + {OTEL_LOG_LEVEL: log_level_value}, + clear=True, + ): + exporter = OTLPSpanExporterForTesting(insecure=True) + # pylint: disable=protected-access + exporter._client = Mock() + partial_success = ExportTracePartialSuccess( + rejected_spans=1, + error_message="Span dropped", + ) + exporter._client.Export.return_value = ( + ExportTraceServiceResponse( + partial_success=partial_success + ) + ) + exporter.export([self.span]) + + mock_logger_debug.assert_called_once_with( + "Partial success:\n%s", partial_success + ) + mock_logger_debug.reset_mock() - @patch.dict("os.environ", {OTEL_LOG_LEVEL: "info"}) @patch("logging.Logger.debug") - def test_does_not_record_partial_success_if_log_level_info( + def test_does_not_record_partial_success_if_log_level_disabled( self, mock_logger_debug ): - exporter = OTLPSpanExporterForTesting(insecure=True) - # pylint: disable=protected-access - exporter._client = Mock() - partial_success = ExportTracePartialSuccess( - rejected_spans=1, - error_message="Span dropped", - ) - exporter._client.Export.return_value = ExportTraceServiceResponse( - partial_success=partial_success - ) - exporter.export([self.span]) - mock_logger_debug.assert_not_called() + test_cases = [None, "off", "error", "info"] + + for log_level_value in test_cases: + with self.subTest(name=f"log_level_{log_level_value or 'unset'}"): + with patch.dict( + "os.environ", + {OTEL_LOG_LEVEL: log_level_value} + if log_level_value is not None + else {}, + clear=True, + ): + exporter = OTLPSpanExporterForTesting(insecure=True) + # pylint: disable=protected-access + exporter._client = Mock() + partial_success = ExportTracePartialSuccess( + rejected_spans=1, + error_message="Span dropped", + ) + exporter._client.Export.return_value = ( + ExportTraceServiceResponse( + partial_success=partial_success + ) + ) + exporter.export([self.span]) + + mock_logger_debug.assert_not_called() + mock_logger_debug.reset_mock() From 818456e0a0a56c9a962988089dc47f52064bf70a Mon Sep 17 00:00:00 2001 From: Michael Safyan Date: Wed, 12 Nov 2025 00:37:13 -0600 Subject: [PATCH 6/6] Undo changes to environment variable docs This appears to cause an issue with "tox -e docs". --- .../src/opentelemetry/sdk/environment_variables/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 087cc4b819..5baf5fcd55 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -50,8 +50,7 @@ """ .. envvar:: OTEL_LOG_LEVEL -The :envvar:`OTEL_LOG_LEVEL` environment variable sets the log level used by the SDK logger. -OTLP exporters will log partial success responses when this is set to `debug` or `verbose`. +The :envvar:`OTEL_LOG_LEVEL` environment variable sets the log level used by the SDK logger Default: "info" """