diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e5d24f170c..1dc1a4882f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @getsentry/team-web-sdk-backend +* @getsentry/owners-python-sdk diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed8b3e4094..a0e39a5784 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + uses: actions/create-github-app-token@3ff1caaa28b64c9cc276ce0a02e2ff584f3900c5 # v2.0.2 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py index df45e30ed9..58dbed0308 100644 --- a/scripts/populate_tox/populate_tox.py +++ b/scripts/populate_tox/populate_tox.py @@ -9,7 +9,7 @@ import time from bisect import bisect_left from collections import defaultdict -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone # noqa: F401 from importlib.metadata import metadata from packaging.specifiers import SpecifierSet from packaging.version import Version @@ -29,6 +29,10 @@ from split_tox_gh_actions.split_tox_gh_actions import GROUPS +# Set CUTOFF this to a datetime to ignore packages older than CUTOFF +CUTOFF = None +# CUTOFF = datetime.now(tz=timezone.utc) - timedelta(days=365 * 5) + TOX_FILE = Path(__file__).resolve().parent.parent.parent / "tox.ini" ENV = Environment( loader=FileSystemLoader(Path(__file__).resolve().parent), @@ -36,6 +40,8 @@ lstrip_blocks=True, ) +PYPI_COOLDOWN = 0.15 # seconds to wait between requests to PyPI + PYPI_PROJECT_URL = "https://pypi.python.org/pypi/{project}/json" PYPI_VERSION_URL = "https://pypi.python.org/pypi/{project}/{version}/json" CLASSIFIER_PREFIX = "Programming Language :: Python :: " @@ -88,27 +94,34 @@ } -@functools.cache -def fetch_package(package: str) -> dict: - """Fetch package metadata from PyPI.""" - url = PYPI_PROJECT_URL.format(project=package) - pypi_data = requests.get(url) +def fetch_url(url: str) -> Optional[dict]: + for attempt in range(3): + pypi_data = requests.get(url) - if pypi_data.status_code != 200: - print(f"{package} not found") + if pypi_data.status_code == 200: + return pypi_data.json() - return pypi_data.json() + backoff = PYPI_COOLDOWN * 2**attempt + print( + f"{url} returned an error: {pypi_data.status_code}. Attempt {attempt + 1}/3. Waiting {backoff}s" + ) + time.sleep(backoff) + + return None @functools.cache -def fetch_release(package: str, version: Version) -> dict: - url = PYPI_VERSION_URL.format(project=package, version=version) - pypi_data = requests.get(url) +def fetch_package(package: str) -> Optional[dict]: + """Fetch package metadata from PyPI.""" + url = PYPI_PROJECT_URL.format(project=package) + return fetch_url(url) - if pypi_data.status_code != 200: - print(f"{package} not found") - return pypi_data.json() +@functools.cache +def fetch_release(package: str, version: Version) -> Optional[dict]: + """Fetch release metadata from PyPI.""" + url = PYPI_VERSION_URL.format(project=package, version=version) + return fetch_url(url) def _prefilter_releases( @@ -153,9 +166,13 @@ def _prefilter_releases( if meta["yanked"]: continue - if older_than is not None: - if datetime.fromisoformat(meta["upload_time_iso_8601"]) > older_than: - continue + uploaded = datetime.fromisoformat(meta["upload_time_iso_8601"]) + + if older_than is not None and uploaded > older_than: + continue + + if CUTOFF is not None and uploaded < CUTOFF: + continue version = Version(release) @@ -229,8 +246,14 @@ def get_supported_releases( expected_python_versions = SpecifierSet(f">={MIN_PYTHON_VERSION}") def _supports_lowest(release: Version) -> bool: - time.sleep(0.1) # don't DoS PYPI - py_versions = determine_python_versions(fetch_release(package, release)) + time.sleep(PYPI_COOLDOWN) # don't DoS PYPI + + pypi_data = fetch_release(package, release) + if pypi_data is None: + print("Failed to fetch necessary data from PyPI. Aborting.") + sys.exit(1) + + py_versions = determine_python_versions(pypi_data) target_python_versions = TEST_SUITE_CONFIG[integration].get("python") if target_python_versions: target_python_versions = SpecifierSet(target_python_versions) @@ -499,7 +522,11 @@ def _add_python_versions_to_release( integration: str, package: str, release: Version ) -> None: release_pypi_data = fetch_release(package, release) - time.sleep(0.1) # give PYPI some breathing room + if release_pypi_data is None: + print("Failed to fetch necessary data from PyPI. Aborting.") + sys.exit(1) + + time.sleep(PYPI_COOLDOWN) # give PYPI some breathing room target_python_versions = TEST_SUITE_CONFIG[integration].get("python") if target_python_versions: @@ -592,6 +619,9 @@ def main(fail_on_changes: bool = False) -> None: # Fetch data for the main package pypi_data = fetch_package(package) + if pypi_data is None: + print("Failed to fetch necessary data from PyPI. Aborting.") + sys.exit(1) # Get the list of all supported releases diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 99cb4ba0d1..f2acb8d489 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -25,6 +25,7 @@ from sentry_sdk.tracing import trace from sentry_sdk.transport import BaseHttpTransport, make_transport from sentry_sdk.consts import ( + SPANDATA, DEFAULT_MAX_VALUE_LENGTH, DEFAULT_OPTIONS, VERSION, @@ -837,6 +838,13 @@ def _capture_experimental_log(self, current_scope, log): return isolation_scope = current_scope.get_isolation_scope() + log["attributes"]["sentry.sdk.name"] = SDK_INFO["name"] + log["attributes"]["sentry.sdk.version"] = SDK_INFO["version"] + + server_name = self.options.get("server_name") + if server_name is not None and SPANDATA.SERVER_ADDRESS not in log["attributes"]: + log["attributes"][SPANDATA.SERVER_ADDRESS] = server_name + environment = self.options.get("environment") if environment is not None and "sentry.environment" not in log["attributes"]: log["attributes"]["sentry.environment"] = environment diff --git a/sentry_sdk/integrations/logging.py b/sentry_sdk/integrations/logging.py index 195ccd5b25..0647740d1f 100644 --- a/sentry_sdk/integrations/logging.py +++ b/sentry_sdk/integrations/logging.py @@ -363,7 +363,9 @@ def _capture_log_from_record(client, record): # type: (BaseClient, LogRecord) -> None scope = sentry_sdk.get_current_scope() otel_severity_number, otel_severity_text = _python_level_to_otel(record.levelno) - attrs = {} # type: dict[str, str | bool | float | int] + attrs = { + "sentry.origin": "auto.logger.log", + } # type: dict[str, str | bool | float | int] if isinstance(record.msg, str): attrs["sentry.message.template"] = record.msg if record.args is not None: diff --git a/sentry_sdk/transport.py b/sentry_sdk/transport.py index ec0b6b2349..ec48f49be4 100644 --- a/sentry_sdk/transport.py +++ b/sentry_sdk/transport.py @@ -166,6 +166,8 @@ def _parse_rate_limits(header, now=None): class BaseHttpTransport(Transport): """The base HTTP transport.""" + TIMEOUT = 30 # seconds + def __init__(self, options): # type: (Self, Dict[str, Any]) -> None from sentry_sdk.consts import VERSION @@ -558,6 +560,7 @@ def _get_pool_options(self): options = { "num_pools": 2 if num_pools is None else int(num_pools), "cert_reqs": "CERT_REQUIRED", + "timeout": urllib3.Timeout(total=self.TIMEOUT), } socket_options = None # type: Optional[List[Tuple[int, int, int | bytes]]] @@ -673,6 +676,8 @@ def __init__(self, options): class Http2Transport(BaseHttpTransport): # type: ignore """The HTTP2 transport based on httpcore.""" + TIMEOUT = 15 + if TYPE_CHECKING: _pool: Union[ httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool @@ -702,6 +707,14 @@ def _request( self._auth.get_api_url(endpoint_type), content=body, headers=headers, # type: ignore + extensions={ + "timeout": { + "pool": self.TIMEOUT, + "connect": self.TIMEOUT, + "write": self.TIMEOUT, + "read": self.TIMEOUT, + } + }, ) return response diff --git a/tests/test_logs.py b/tests/test_logs.py index f4c7b19049..01fe574726 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -11,6 +11,7 @@ from sentry_sdk.envelope import Envelope from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.types import Log +from sentry_sdk.consts import SPANDATA, VERSION minimum_python_37 = pytest.mark.skipif( sys.version_info < (3, 7), reason="Asyncio tests need Python >= 3.7" @@ -161,7 +162,7 @@ def test_logs_attributes(sentry_init, capture_envelopes): """ Passing arbitrary attributes to log messages. """ - sentry_init(_experiments={"enable_logs": True}) + sentry_init(_experiments={"enable_logs": True}, server_name="test-server") envelopes = capture_envelopes() attrs = { @@ -184,6 +185,9 @@ def test_logs_attributes(sentry_init, capture_envelopes): assert logs[0]["attributes"]["sentry.environment"] == "production" assert "sentry.release" in logs[0]["attributes"] assert logs[0]["attributes"]["sentry.message.parameters.my_var"] == "some value" + assert logs[0]["attributes"][SPANDATA.SERVER_ADDRESS] == "test-server" + assert logs[0]["attributes"]["sentry.sdk.name"] == "sentry.python" + assert logs[0]["attributes"]["sentry.sdk.version"] == VERSION @minimum_python_37 @@ -283,6 +287,7 @@ def test_logger_integration_warning(sentry_init, capture_envelopes): assert attrs["sentry.environment"] == "production" assert attrs["sentry.message.parameters.0"] == "1" assert attrs["sentry.message.parameters.1"] == "2" + assert attrs["sentry.origin"] == "auto.logger.log" assert logs[0]["severity_number"] == 13 assert logs[0]["severity_text"] == "warn" diff --git a/tests/test_transport.py b/tests/test_transport.py index cfe526610c..dccb91804e 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -14,6 +14,16 @@ from pytest_localserver.http import WSGIServer from werkzeug.wrappers import Request, Response +try: + import httpcore +except (ImportError, ModuleNotFoundError): + httpcore = None + +try: + import gevent +except ImportError: + gevent = None + import sentry_sdk from sentry_sdk import ( Client, @@ -260,6 +270,37 @@ def test_keep_alive_on_by_default(make_client): assert "socket_options" not in options +def test_default_timeout(make_client): + client = make_client() + + options = client.transport._get_pool_options() + assert "timeout" in options + assert options["timeout"].total == client.transport.TIMEOUT + + +@pytest.mark.skipif(not PY38, reason="HTTP2 libraries are only available in py3.8+") +def test_default_timeout_http2(make_client): + client = make_client(_experiments={"transport_http2": True}) + + with mock.patch( + "sentry_sdk.transport.httpcore.ConnectionPool.request", + return_value=httpcore.Response(200), + ) as request_mock: + sentry_sdk.get_global_scope().set_client(client) + capture_message("hi") + client.flush() + + request_mock.assert_called_once() + assert request_mock.call_args.kwargs["extensions"] == { + "timeout": { + "pool": client.transport.TIMEOUT, + "connect": client.transport.TIMEOUT, + "write": client.transport.TIMEOUT, + "read": client.transport.TIMEOUT, + } + } + + @pytest.mark.skipif(not PY38, reason="HTTP2 libraries are only available in py3.8+") def test_http2_with_https_dsn(make_client): client = make_client(_experiments={"transport_http2": True}) diff --git a/tox.ini b/tox.ini index f7137389ac..e924e6a3b1 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-04-04T13:09:23.106982+00:00 +# Last generated: 2025-04-08T10:33:11.499210+00:00 [tox] requires = @@ -200,7 +200,7 @@ envlist = {py3.8,py3.10,py3.11}-strawberry-v0.209.8 {py3.8,py3.11,py3.12}-strawberry-v0.227.7 {py3.8,py3.11,py3.12}-strawberry-v0.245.0 - {py3.9,py3.12,py3.13}-strawberry-v0.263.1 + {py3.9,py3.12,py3.13}-strawberry-v0.263.2 # ~~~ Network ~~~ @@ -211,9 +211,9 @@ envlist = # ~~~ Tasks ~~~ - {py3.8}-celery-v4.4.7 - {py3.8}-celery-v5.0.5 - {py3.8,py3.12,py3.13}-celery-v5.5.0 + {py3.6,py3.7,py3.8}-celery-v4.4.7 + {py3.6,py3.7,py3.8}-celery-v5.0.5 + {py3.8,py3.12,py3.13}-celery-v5.5.1 {py3.7}-dramatiq-v1.9.0 {py3.7,py3.8,py3.9}-dramatiq-v1.12.3 @@ -256,7 +256,7 @@ envlist = {py3.8,py3.10,py3.11}-litestar-v2.0.1 {py3.8,py3.11,py3.12}-litestar-v2.5.5 {py3.8,py3.11,py3.12}-litestar-v2.10.0 - {py3.8,py3.12,py3.13}-litestar-v2.15.1 + {py3.8,py3.12,py3.13}-litestar-v2.15.2 {py3.7,py3.8,py3.9}-pyramid-v1.10.8 {py3.7,py3.10,py3.11}-pyramid-v2.0.2 @@ -564,7 +564,7 @@ deps = strawberry-v0.209.8: strawberry-graphql[fastapi,flask]==0.209.8 strawberry-v0.227.7: strawberry-graphql[fastapi,flask]==0.227.7 strawberry-v0.245.0: strawberry-graphql[fastapi,flask]==0.245.0 - strawberry-v0.263.1: strawberry-graphql[fastapi,flask]==0.263.1 + strawberry-v0.263.2: strawberry-graphql[fastapi,flask]==0.263.2 strawberry: httpx strawberry-v0.209.8: pydantic<2.11 strawberry-v0.227.7: pydantic<2.11 @@ -585,7 +585,7 @@ deps = # ~~~ Tasks ~~~ celery-v4.4.7: celery==4.4.7 celery-v5.0.5: celery==5.0.5 - celery-v5.5.0: celery==5.5.0 + celery-v5.5.1: celery==5.5.1 celery: newrelic celery: redis @@ -663,7 +663,7 @@ deps = litestar-v2.0.1: litestar==2.0.1 litestar-v2.5.5: litestar==2.5.5 litestar-v2.10.0: litestar==2.10.0 - litestar-v2.15.1: litestar==2.15.1 + litestar-v2.15.2: litestar==2.15.2 litestar: pytest-asyncio litestar: python-multipart litestar: requests