Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- `opentelemetry-exporter-otlp-proto-http`: Log server error details from response body on export failure
([#5155](https://github.com/open-telemetry/opentelemetry-python/pull/5155))
- `opentelemetry-sdk`: add `additional_properties` support to generated config models via custom `datamodel-codegen` template, enabling plugin/custom component names to flow through typed dataclasses
([#5131](https://github.com/open-telemetry/opentelemetry-python/pull/5131))
- Fix incorrect code example in `create_tracer()` docstring
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,73 @@
# 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

import requests
from google.rpc.status_pb2 import Status

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) -> str:
"""Parse an HTTP response body based on its Content-Type header.

Per the OTLP spec, error responses (4xx/5xx) use ``google.rpc.Status``
for protobuf bodies and the equivalent JSON representation.

Args:
resp: The HTTP response from the OTLP endpoint.

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", "").split(";", 1)[0].strip().lower()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why do we split on semi-colon, shouldn't we do it on comma ? https://requests.readthedocs.io/en/latest/user/quickstart/#response-headers -- mentions comma

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The semicolon split is intentional here. Per https://www.rfc-editor.org/rfc/rfc9110#section-8.3.1, the Content-Type header uses semicolons to separate the media type from parameters like charset:

Content-Type: application/x-protobuf; charset=utf-8

I believe the comma behavior you linked is about how requests combines multiple values of the same header into a single string - that's a different concept. Since Content-Type only has one value with optional ;-delimited parameters, splitting on ; is correct to extract just the media type portion.

)

if content_type == _CONTENT_TYPE_PROTOBUF:
status = Status()
try:
status.ParseFromString(resp.content)
except Exception: # pylint: disable=broad-except
Comment thread
grvmishra788 marked this conversation as resolved.
_logger.debug(
"Failed to parse protobuf response body", exc_info=True
)
return resp.reason
return status.message or resp.reason

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

return resp.text.strip() 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,7 @@
from opentelemetry.exporter.otlp.proto.http._common import (
_is_retryable,
_load_session_from_envvar,
_parse_response_body,
)
from opentelemetry.metrics import MeterProvider
from opentelemetry.sdk._logs import ReadableLogRecord
Expand Down Expand Up @@ -220,7 +221,7 @@ def export(
retryable = isinstance(error, ConnectionError)
status_code = None
else:
reason = resp.reason
reason = _parse_response_body(resp)
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)
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,7 @@
from opentelemetry.exporter.otlp.proto.http._common import (
_is_retryable,
_load_session_from_envvar,
_parse_response_body,
)
from opentelemetry.metrics import MeterProvider
from opentelemetry.sdk.environment_variables import (
Expand Down Expand Up @@ -213,7 +214,7 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
retryable = isinstance(error, ConnectionError)
status_code = None
else:
reason = resp.reason
reason = _parse_response_body(resp)
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 @@ -23,6 +24,7 @@

import requests
from google.protobuf.json_format import MessageToDict
from google.rpc.status_pb2 import Status
from requests import Session
from requests.exceptions import ConnectionError
from requests.models import Response
Expand Down Expand Up @@ -85,6 +87,11 @@ 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"
)
log_exporter_logger.filters.clear()

def test_constructor_default(self):
exporter = OTLPLogExporter()
Expand Down Expand Up @@ -661,6 +668,25 @@ 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):
status = Status(code=3, message="invalid log data")
resp = Response()
resp.status_code = 400
resp.reason = "Bad Request"
resp._content = status.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,13 +12,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import json
import threading
import time
import unittest
from logging import WARNING
from unittest.mock import MagicMock, Mock, patch

import requests
from google.rpc.status_pb2 import Status
from requests import Session
from requests.exceptions import ConnectionError
from requests.models import Response
Expand Down Expand Up @@ -479,6 +481,44 @@ 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):
status = Status(code=3, message="invalid span data")
resp = Response()
resp.status_code = 400
resp.reason = "Bad Request"
resp._content = status.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
Loading