diff --git a/CHANGELOG.md b/CHANGELOG.md index b2155602a2..221860c24b 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 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)) - build: bump ruff to 0.14.1 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 63d8ac9cfb..8b44f9fa26 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,6 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys from os import environ from typing import Dict, Literal, Optional, Sequence, Tuple, Union from typing import Sequence as TypingSequence @@ -112,6 +113,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[ReadableLogRecord], 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 be86e5b0cf..4b25b6fd4c 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 @@ -374,6 +374,13 @@ def _translate_data( ) -> ExportServiceRequestT: pass + def _log_partial_success(self, partial_success): + logger.debug("Partial success:\n%s", partial_success) + + def _process_response(self, response): + if response.HasField("partial_success"): + self._log_partial_success(response.partial_success) + def _export( self, data: SDKDataT, @@ -388,11 +395,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] 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 94e8cc944c..4ea40a5ad8 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 @@ -15,6 +15,7 @@ # pylint: disable=too-many-lines import time +from io import StringIO from os.path import dirname from unittest import TestCase from unittest.mock import Mock, patch @@ -28,7 +29,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 ( @@ -316,6 +319,26 @@ def export_log_and_deserialize(self, log_data): ) return log_records + @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 9fc739522d..9c2054e056 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, ) @@ -534,3 +535,22 @@ def test_permanent_failure(self): warning.records[-1].message, "Failed to export traces to localhost:4317, error code: StatusCode.ALREADY_EXISTS", ) + + @patch("logging.Logger.debug") + def test_records_partial_success(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 + ) + mock_logger_debug.reset_mock()