Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,77 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
from os import environ
from typing import Literal, Optional
from typing import Literal, Optional, Type

import requests
from google.protobuf.message import Message

from opentelemetry.sdk.environment_variables import (
_OTEL_PYTHON_EXPORTER_OTLP_HTTP_CREDENTIAL_PROVIDER,
)
from opentelemetry.util._importlib_metadata import entry_points

_logger = logging.getLogger(__name__)

_CONTENT_TYPE_PROTOBUF = "application/x-protobuf"
_CONTENT_TYPE_JSON = "application/json"


def _parse_response_body(
resp: requests.Response, response_class: Type[Message]
) -> str:
"""Parse an HTTP response body based on its Content-Type header.

Args:
resp: The HTTP response from the OTLP endpoint.
response_class: The protobuf message class to use for deserialization
when the response content-type is ``application/x-protobuf``.

Returns:
A human-readable string describing the response body error details,
or ``resp.reason`` if the body is empty or cannot be parsed.
"""
if not resp.content:
return resp.reason

content_type = resp.headers.get("Content-Type", "")

if content_type.startswith(_CONTENT_TYPE_PROTOBUF):
try:
message = response_class()
message.ParseFromString(resp.content)
partial_success = getattr(message, "partial_success", None)
if partial_success is not None:
error_message = getattr(partial_success, "error_message", "")
if error_message:
return error_message
except Exception: # pylint: disable=broad-except
_logger.debug(
"Failed to parse protobuf response body", exc_info=True
)
return resp.reason

if content_type.startswith(_CONTENT_TYPE_JSON):
try:
body = resp.json()
if isinstance(body, dict):
# OTLP partial_success uses camelCase in JSON
partial = body.get("partialSuccess", {})
error_message = partial.get("errorMessage", "")
if error_message:
return error_message
# google.rpc.Status uses "message"
rpc_message = body.get("message", "")
if rpc_message:
return rpc_message
except Exception: # pylint: disable=broad-except
_logger.debug("Failed to parse JSON response body", exc_info=True)
return resp.text or resp.reason

return resp.text or resp.reason


def _is_retryable(resp: requests.Response) -> bool:
if resp.status_code == 408:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
from opentelemetry.exporter.otlp.proto.http._common import (
_is_retryable,
_load_session_from_envvar,
_parse_response_body,
)
from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import (
ExportLogsServiceResponse,
)
from opentelemetry.metrics import MeterProvider
from opentelemetry.sdk._logs import ReadableLogRecord
Expand Down Expand Up @@ -220,7 +224,7 @@ def export(
retryable = isinstance(error, ConnectionError)
status_code = None
else:
reason = resp.reason
reason = _parse_response_body(resp, ExportLogsServiceResponse)
retryable = _is_retryable(resp)
status_code = resp.status_code

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@
from opentelemetry.exporter.otlp.proto.http._common import (
_is_retryable,
_load_session_from_envvar,
_parse_response_body,
)
from opentelemetry.metrics import MeterProvider
from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import ( # noqa: F401
ExportMetricsServiceRequest,
ExportMetricsServiceResponse,
)
from opentelemetry.proto.common.v1.common_pb2 import ( # noqa: F401
AnyValue,
Expand Down Expand Up @@ -293,7 +295,7 @@ def _export_with_retries(
retryable = isinstance(error, ConnectionError)
status_code = None
else:
reason = resp.reason
reason = _parse_response_body(resp, ExportMetricsServiceResponse)
retryable = _is_retryable(resp)
status_code = resp.status_code

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
from opentelemetry.exporter.otlp.proto.http._common import (
_is_retryable,
_load_session_from_envvar,
_parse_response_body,
)
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
ExportTraceServiceResponse,
)
from opentelemetry.metrics import MeterProvider
from opentelemetry.sdk.environment_variables import (
Expand Down Expand Up @@ -213,7 +217,7 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
retryable = isinstance(error, ConnectionError)
status_code = None
else:
reason = resp.reason
reason = _parse_response_body(resp, ExportTraceServiceResponse)
retryable = _is_retryable(resp)
status_code = resp.status_code

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

# pylint: disable=protected-access

import logging
import threading
import time
import unittest
Expand All @@ -38,7 +39,9 @@
)
from opentelemetry.exporter.otlp.proto.http.version import __version__
from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import (
ExportLogsPartialSuccess,
ExportLogsServiceRequest,
ExportLogsServiceResponse,
)
from opentelemetry.sdk._logs import ReadWriteLogRecord
from opentelemetry.sdk._logs.export import LogRecordExportResult
Expand Down Expand Up @@ -85,6 +88,13 @@ def setUp(self):
self.meter_provider = MeterProvider(
metric_readers=[self.metric_reader]
)
# Reset DuplicateFilter state between tests so each test can log freely.
log_exporter_logger = logging.getLogger(
"opentelemetry.exporter.otlp.proto.http._log_exporter"
)
for log_filter in log_exporter_logger.filters:
if hasattr(log_filter, "last_log"):
del log_filter.last_log

def test_constructor_default(self):
exporter = OTLPLogExporter()
Expand Down Expand Up @@ -661,6 +671,30 @@ def test_shutdown_interrupts_retry_backoff(self, mock_post):

assert after - before < 0.2

@patch.object(Session, "post")
def test_error_response_with_protobuf_body(self, mock_post):
proto_response = ExportLogsServiceResponse(
partial_success=ExportLogsPartialSuccess(
rejected_log_records=2,
error_message="invalid log data",
)
)
resp = Response()
resp.status_code = 400
resp.reason = "Bad Request"
resp._content = proto_response.SerializeToString() # pylint: disable=protected-access
resp.headers["Content-Type"] = "application/x-protobuf"
mock_post.return_value = resp

exporter = OTLPLogExporter()
with self.assertLogs(level="ERROR") as logs:
result = exporter.export(self._get_sdk_log_data())

self.assertEqual(result, LogRecordExportResult.FAILURE)
self.assertTrue(
any("invalid log data" in r.message for r in logs.records)
)

def assert_standard_metric_attrs(self, attributes):
self.assertEqual(
attributes["otel.component.type"], "otlp_http_log_exporter"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import json
import threading
import time
import unittest
Expand Down Expand Up @@ -51,6 +52,10 @@
)
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
ExportTracePartialSuccess,
ExportTraceServiceResponse,
)
from opentelemetry.sdk.trace import _Span
from opentelemetry.sdk.trace.export import SpanExportResult
from opentelemetry.test.mock_test_classes import IterEntryPoint
Expand Down Expand Up @@ -479,6 +484,49 @@ def test_shutdown_interrupts_retry_backoff(self, mock_post):

assert after - before < 0.2

@patch.object(Session, "post")
def test_error_response_with_protobuf_body(self, mock_post):
proto_response = ExportTraceServiceResponse(
partial_success=ExportTracePartialSuccess(
rejected_spans=1,
error_message="invalid span data",
)
)
resp = Response()
resp.status_code = 400
resp.reason = "Bad Request"
resp._content = proto_response.SerializeToString() # pylint: disable=protected-access
resp.headers["Content-Type"] = "application/x-protobuf"
mock_post.return_value = resp

exporter = OTLPSpanExporter()
with self.assertLogs(level="ERROR") as logs:
result = exporter.export([BASIC_SPAN])

self.assertEqual(result, SpanExportResult.FAILURE)
self.assertTrue(
any("invalid span data" in r.message for r in logs.records)
)

@patch.object(Session, "post")
def test_error_response_with_json_body(self, mock_post):
body = json.dumps({"message": "quota limit reached"}).encode()
resp = Response()
resp.status_code = 400
resp.reason = "Bad Request"
resp._content = body # pylint: disable=protected-access
resp.headers["Content-Type"] = "application/json"
mock_post.return_value = resp

exporter = OTLPSpanExporter()
with self.assertLogs(level="ERROR") as logs:
result = exporter.export([BASIC_SPAN])

self.assertEqual(result, SpanExportResult.FAILURE)
self.assertTrue(
any("quota limit reached" in r.message for r in logs.records)
)

def assert_standard_metric_attrs(self, attributes):
self.assertEqual(
attributes["otel.component.type"], "otlp_http_span_exporter"
Expand Down
Loading