diff --git a/sentry_sdk/transport.py b/sentry_sdk/transport.py index 1bdc7c6a7d..cf19f6d16b 100644 --- a/sentry_sdk/transport.py +++ b/sentry_sdk/transport.py @@ -1,14 +1,15 @@ -from abc import ABC, abstractmethod import asyncio +import gzip import io +import json import os -import gzip import socket import ssl import time import warnings -from datetime import datetime, timedelta, timezone +from abc import ABC, abstractmethod from collections import defaultdict +from datetime import datetime, timedelta, timezone from urllib.request import getproxies try: @@ -35,36 +36,37 @@ except ImportError: ASYNC_TRANSPORT_AVAILABLE = False -import urllib3 +from typing import TYPE_CHECKING, Dict, List, cast + import certifi +import urllib3 import sentry_sdk from sentry_sdk.consts import EndpointType +from sentry_sdk.envelope import Envelope, Item, PayloadRef from sentry_sdk.utils import ( Dsn, - logger, capture_internal_exceptions, + logger, mark_sentry_task_internal, ) -from sentry_sdk.worker import BackgroundWorker, Worker, AsyncWorker -from sentry_sdk.envelope import Envelope, Item, PayloadRef - -from typing import TYPE_CHECKING, cast, List, Dict +from sentry_sdk.worker import AsyncWorker, BackgroundWorker, Worker if TYPE_CHECKING: - from typing import Any - from typing import Callable - from typing import DefaultDict - from typing import Iterable - from typing import Mapping - from typing import Optional - from typing import Self - from typing import Tuple - from typing import Type - from typing import Union - - from urllib3.poolmanager import PoolManager - from urllib3.poolmanager import ProxyManager + from typing import ( + Any, + Callable, + DefaultDict, + Iterable, + Mapping, + Optional, + Self, + Tuple, + Type, + Union, + ) + + from urllib3.poolmanager import PoolManager, ProxyManager from sentry_sdk._types import Event, EventDataCategory @@ -1081,6 +1083,74 @@ def _make_pool( return httpcore.ConnectionPool(**opts) +class _EnvelopePrinterTransport(Transport): + """Wraps another transport, printing envelope contents to the SDK debug logger before sending.""" + + def __init__(self, transport: "Transport") -> None: + Transport.__init__(self, options=transport.options) + self._inner = transport + self.parsed_dsn = transport.parsed_dsn + + @property # type: ignore[misc] + def __class__(self) -> type: + return self._inner.__class__ + + def capture_envelope(self, envelope: "Envelope") -> None: + try: + logger.debug("--- Sentry Envelope ---") + logger.debug( + "Headers: %s", json.dumps(envelope.headers, indent=2, default=str) + ) + for item in envelope.items: + logger.debug(" Item type: %s", item.type) + logger.debug( + " Item headers: %s", + json.dumps(item.headers, indent=2, default=str), + ) + try: + payload = json.loads(item.get_bytes()) + logger.debug( + " Payload:\n%s", + json.dumps(payload, indent=2, default=str), + ) + except (ValueError, TypeError): + logger.debug( + " Payload: ", + len(item.get_bytes()), + ) + logger.debug("--- End Envelope ---") + except Exception: + pass + + self._inner.capture_envelope(envelope) + + def flush( + self, + timeout: float, + callback: "Optional[Any]" = None, + ) -> "Any": + return self._inner.flush(timeout, callback) + + def kill(self) -> "Any": + return self._inner.kill() + + def record_lost_event( + self, + reason: str, + data_category: "Optional[EventDataCategory]" = None, + item: "Optional[Item]" = None, + *, + quantity: int = 1, + ) -> None: + self._inner.record_lost_event(reason, data_category, item, quantity=quantity) + + def is_healthy(self) -> bool: + return self._inner.is_healthy() + + def __getattr__(self, name: str) -> "Any": + return getattr(self._inner, name) + + class _FunctionTransport(Transport): """ DEPRECATED: Users wishing to provide a custom transport should subclass @@ -1147,8 +1217,10 @@ def make_transport(options: "Dict[str, Any]") -> "Optional[Transport]": "You tried to use AsyncHttpTransport but don't have httpcore[asyncio] installed. Falling back to sync transport." ) + transport = None # type: Optional[Transport] + if isinstance(ref_transport, Transport): - return ref_transport + transport = ref_transport elif isinstance(ref_transport, type) and issubclass(ref_transport, Transport): transport_cls = ref_transport elif callable(ref_transport): @@ -1158,11 +1230,16 @@ def make_transport(options: "Dict[str, Any]") -> "Optional[Transport]": DeprecationWarning, stacklevel=2, ) - return _FunctionTransport(ref_transport) + transport = _FunctionTransport(ref_transport) # if a transport class is given only instantiate it if the dsn is not # empty or None - if options["dsn"]: - return transport_cls(options) + if transport is None and options["dsn"]: + transport = transport_cls(options) + + if transport is not None and os.environ.get( + "SENTRY_PRINT_ENVELOPES", "" + ).lower() in ("1", "true", "yes"): + transport = _EnvelopePrinterTransport(transport) - return None + return transport diff --git a/tests/test_transport.py b/tests/test_transport.py index a121d3f1be..774d6d78a7 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,14 +1,15 @@ +import asyncio import logging -import pickle import os +import pickle import socket import sys -import asyncio from collections import defaultdict from datetime import datetime, timedelta, timezone from unittest import mock import pytest + from tests.conftest import CapturingServer try: @@ -30,23 +31,26 @@ import sentry_sdk from sentry_sdk import ( Client, + Hub, add_breadcrumb, capture_message, - isolation_scope, get_isolation_scope, - Hub, + isolation_scope, ) from sentry_sdk._compat import PY37, PY38 -from sentry_sdk.envelope import Envelope, Item, parse_json, PayloadRef +from sentry_sdk.envelope import Envelope, Item, PayloadRef, parse_json +from sentry_sdk.integrations.asyncio import AsyncioIntegration +from sentry_sdk.integrations.logging import LoggingIntegration, ignore_logger from sentry_sdk.transport import ( KEEP_ALIVE_SOCKET_OPTIONS, - _parse_rate_limits, AsyncHttpTransport, HttpTransport, + Transport, + _EnvelopePrinterTransport, + _parse_rate_limits, + make_transport, ) -from sentry_sdk.integrations.logging import LoggingIntegration, ignore_logger -from sentry_sdk.integrations.asyncio import AsyncioIntegration - +from sentry_sdk.utils import Dsn server = None @@ -1041,3 +1045,329 @@ async def send_message(i): # New request should be dropped due to rate limiting assert len(capturing_server.captured) == 0 await client.close_async(timeout=2.0) + + +# --- _EnvelopePrinterTransport tests --- + + +class FakeTransport(Transport): + def __init__(self): + self.options = {"dsn": "https://foo@sentry.io/123"} + self.parsed_dsn = Dsn("https://foo@sentry.io/123") + self.captured_envelopes = [] + self.flushed = [] + self.killed = False + self.lost_events = [] + self._healthy = True + + def capture_envelope(self, envelope): + self.captured_envelopes.append(envelope) + + def flush(self, timeout, callback=None): + self.flushed.append((timeout, callback)) + + def kill(self): + self.killed = True + + def record_lost_event(self, reason, data_category=None, item=None, *, quantity=1): + self.lost_events.append((reason, data_category, item, quantity)) + + def is_healthy(self): + return self._healthy + + +def _make_json_envelope(): + envelope = Envelope(headers={"event_id": "abc123"}) + envelope.add_item(Item(payload='{"message": "hello"}', type="event")) + return envelope + + +def _make_binary_envelope(): + envelope = Envelope(headers={"event_id": "bin456"}) + envelope.add_item(Item(payload=b"\x00\x01\x02\x03", type="attachment")) + return envelope + + +class Test_EnvelopePrinterTransportDelegation: + def test_capture_envelope_delegates(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + envelope = _make_json_envelope() + + transport.capture_envelope(envelope) + + assert inner.captured_envelopes == [envelope] + + def test_flush_delegates(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + cb = lambda: None + + transport.flush(10.0, cb) + + assert inner.flushed == [(10.0, cb)] + + def test_kill_delegates(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + + transport.kill() + + assert inner.killed is True + + def test_record_lost_event_delegates(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + item = Item(payload='{"x": 1}', type="event") + + transport.record_lost_event("queue_overflow", "error", item, quantity=5) + + assert inner.lost_events == [("queue_overflow", "error", item, 5)] + + def test_is_healthy_delegates(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + + assert transport.is_healthy() is True + + inner._healthy = False + assert transport.is_healthy() is False + + def test_parsed_dsn_forwarded(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + + assert transport.parsed_dsn is inner.parsed_dsn + + +def _collect_debug_log_text(mock_debug): + parts = [] + for call in mock_debug.call_args_list: + args = call[0] + if len(args) == 1: + parts.append(args[0]) + else: + parts.append(args[0] % args[1:]) + return "\n".join(parts) + + +class Test_EnvelopePrinterTransportLogging: + def test_json_payload_is_pretty_printed(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + envelope = _make_json_envelope() + + with mock.patch("sentry_sdk.transport.logger") as mock_logger: + transport.capture_envelope(envelope) + + log_text = _collect_debug_log_text(mock_logger.debug) + assert '"message": "hello"' in log_text + + def test_binary_payload_shows_byte_count(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + envelope = _make_binary_envelope() + + with mock.patch("sentry_sdk.transport.logger") as mock_logger: + transport.capture_envelope(envelope) + + log_text = _collect_debug_log_text(mock_logger.debug) + assert "" in log_text + + def test_envelope_headers_are_logged(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + envelope = _make_json_envelope() + + with mock.patch("sentry_sdk.transport.logger") as mock_logger: + transport.capture_envelope(envelope) + + log_text = _collect_debug_log_text(mock_logger.debug) + assert "abc123" in log_text + + def test_item_type_and_headers_are_logged(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + envelope = _make_json_envelope() + + with mock.patch("sentry_sdk.transport.logger") as mock_logger: + transport.capture_envelope(envelope) + + log_text = _collect_debug_log_text(mock_logger.debug) + assert "Item type:" in log_text + assert "event" in log_text + assert "Item headers:" in log_text + + def test_logging_exception_does_not_propagate(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + envelope = _make_json_envelope() + + with mock.patch( + "sentry_sdk.transport.json.dumps", side_effect=RuntimeError("boom") + ): + transport.capture_envelope(envelope) + + assert inner.captured_envelopes == [envelope] + + def test_empty_envelope_logs_headers_only(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + envelope = Envelope(headers={"event_id": "empty1"}) + + with mock.patch("sentry_sdk.transport.logger") as mock_logger: + transport.capture_envelope(envelope) + + log_text = _collect_debug_log_text(mock_logger.debug) + assert "empty1" in log_text + assert "Item type:" not in log_text + assert inner.captured_envelopes == [envelope] + + def test_multiple_items_each_logged(self): + inner = FakeTransport() + transport = _EnvelopePrinterTransport(inner) + envelope = Envelope(headers={"event_id": "multi"}) + envelope.add_item(Item(payload='{"a": 1}', type="event")) + envelope.add_item(Item(payload=b"\xff", type="attachment")) + + with mock.patch("sentry_sdk.transport.logger") as mock_logger: + transport.capture_envelope(envelope) + + log_text = _collect_debug_log_text(mock_logger.debug) + assert '"a": 1' in log_text + assert "" in log_text + + +class TestMakeTransportEnvelopePrinter: + def test_env_var_wraps_transport(self): + options = { + "dsn": "https://foo@sentry.io/123", + "transport": None, + "_experiments": {}, + "integrations": [], + "send_client_reports": True, + "transport_queue_size": 100, + "keep_alive": False, + "socket_options": None, + "ca_certs": None, + "cert_file": None, + "key_file": None, + "http_proxy": None, + "https_proxy": None, + "proxy_headers": None, + } + with mock.patch.dict(os.environ, {"SENTRY_PRINT_ENVELOPES": "1"}): + transport = make_transport(options) + + assert isinstance(transport, _EnvelopePrinterTransport) + assert isinstance(transport._inner, HttpTransport) + + def test_no_env_var_returns_plain_transport(self): + options = { + "dsn": "https://foo@sentry.io/123", + "transport": None, + "_experiments": {}, + "integrations": [], + "send_client_reports": True, + "transport_queue_size": 100, + "keep_alive": False, + "socket_options": None, + "ca_certs": None, + "cert_file": None, + "key_file": None, + "http_proxy": None, + "https_proxy": None, + "proxy_headers": None, + } + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("SENTRY_PRINT_ENVELOPES", None) + transport = make_transport(options) + + assert isinstance(transport, HttpTransport) + assert not isinstance(transport, _EnvelopePrinterTransport) + + @pytest.mark.parametrize("value", ["0", "false", "no", ""]) + def test_falsy_env_var_values_do_not_wrap(self, value): + options = { + "dsn": "https://foo@sentry.io/123", + "transport": None, + "_experiments": {}, + "integrations": [], + "send_client_reports": True, + "transport_queue_size": 100, + "keep_alive": False, + "socket_options": None, + "ca_certs": None, + "cert_file": None, + "key_file": None, + "http_proxy": None, + "https_proxy": None, + "proxy_headers": None, + } + with mock.patch.dict(os.environ, {"SENTRY_PRINT_ENVELOPES": value}): + transport = make_transport(options) + + assert isinstance(transport, HttpTransport) + assert not isinstance(transport, _EnvelopePrinterTransport) + + def test_env_var_wraps_pre_instantiated_transport(self): + inner = FakeTransport() + options = { + "dsn": "https://foo@sentry.io/123", + "transport": inner, + "_experiments": {}, + "integrations": [], + "send_client_reports": True, + "transport_queue_size": 100, + "keep_alive": False, + "socket_options": None, + "ca_certs": None, + "cert_file": None, + "key_file": None, + "http_proxy": None, + "https_proxy": None, + "proxy_headers": None, + } + with mock.patch.dict(os.environ, {"SENTRY_PRINT_ENVELOPES": "1"}): + transport = make_transport(options) + + assert isinstance(transport, _EnvelopePrinterTransport) + assert transport._inner is inner + + +class Test_EnvelopePrinterTransportIsinstance: + def test_isinstance_matches_inner_transport_class(self): + inner = FakeTransport() + wrapper = _EnvelopePrinterTransport(inner) + + assert isinstance(wrapper, FakeTransport) + assert isinstance(wrapper, Transport) + + def test_isinstance_matches_http_transport(self): + options = { + "dsn": "https://foo@sentry.io/123", + "transport": None, + "_experiments": {}, + "integrations": [], + "send_client_reports": True, + "transport_queue_size": 100, + "keep_alive": False, + "socket_options": None, + "ca_certs": None, + "cert_file": None, + "key_file": None, + "http_proxy": None, + "https_proxy": None, + "proxy_headers": None, + } + with mock.patch.dict(os.environ, {"SENTRY_PRINT_ENVELOPES": "1"}): + transport = make_transport(options) + + assert isinstance(transport, HttpTransport) + + def test_getattr_delegates_to_inner(self): + inner = FakeTransport() + inner.custom_attr = "test_value" + wrapper = _EnvelopePrinterTransport(inner) + + assert wrapper.custom_attr == "test_value"