diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb35eb1eb..eb45f28c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2.19.1 + +### Various fixes & improvements + +- Fix errors when instrumenting Django cache (#3855) by @BYK +- Copy `scope.client` reference as well (#3857) by @sl0thentr0py +- Don't give up on Spotlight on 3 errors (#3856) by @BYK +- Add missing stack frames (#3673) by @antonpirker +- Fix wrong metadata type in async gRPC interceptor (#3205) by @fdellekart +- Rename launch darkly hook to match JS SDK (#3743) by @aliu39 +- Script for checking if our instrumented libs are Python 3.13 compatible (#3425) by @antonpirker +- Improve Ray tests (#3846) by @antonpirker +- Test with Celery `5.5.0rc3` (#3842) by @sentrivana +- Fix asyncio testing setup (#3832) by @sl0thentr0py +- Bump `codecov/codecov-action` from `5.0.2` to `5.0.7` (#3821) by @dependabot +- Fix CI (#3834) by @sentrivana +- Use new ClickHouse GH action (#3826) by @antonpirker + ## 2.19.0 ### Various fixes & improvements diff --git a/docs/conf.py b/docs/conf.py index 55d5295381..4f5c210322 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ copyright = "2019-{}, Sentry Team and Contributors".format(datetime.now().year) author = "Sentry Team and Contributors" -release = "2.19.0" +release = "2.19.1" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/scripts/ready_yet/main.py b/scripts/ready_yet/main.py new file mode 100644 index 0000000000..bba97d0c98 --- /dev/null +++ b/scripts/ready_yet/main.py @@ -0,0 +1,124 @@ +import time +import re +import sys + +import requests + +from collections import defaultdict + +from pathlib import Path + +from tox.config.cli.parse import get_options +from tox.session.state import State +from tox.config.sets import CoreConfigSet +from tox.config.source.tox_ini import ToxIni + +PYTHON_VERSION = "3.13" + +MATCH_LIB_SENTRY_REGEX = r"py[\d\.]*-(.*)-.*" + +PYPI_PROJECT_URL = "https://pypi.python.org/pypi/{project}/json" +PYPI_VERSION_URL = "https://pypi.python.org/pypi/{project}/{version}/json" + + +def get_tox_envs(tox_ini_path: Path) -> list: + tox_ini = ToxIni(tox_ini_path) + conf = State(get_options(), []).conf + tox_section = next(tox_ini.sections()) + core_config_set = CoreConfigSet( + conf, tox_section, tox_ini_path.parent, tox_ini_path + ) + ( + core_config_set.loaders.extend( + tox_ini.get_loaders( + tox_section, + base=[], + override_map=defaultdict(list, {}), + conf=core_config_set, + ) + ) + ) + return core_config_set.load("env_list") + + +def get_libs(tox_ini: Path, regex: str) -> set: + libs = set() + for env in get_tox_envs(tox_ini): + match = re.match(regex, env) + if match: + libs.add(match.group(1)) + + return sorted(libs) + + +def main(): + """ + Check if libraries in our tox.ini are ready for Python version defined in `PYTHON_VERSION`. + """ + print(f"Checking libs from tox.ini for Python {PYTHON_VERSION} compatibility:") + + ready = set() + not_ready = set() + not_found = set() + + tox_ini = Path(__file__).parent.parent.parent.joinpath("tox.ini") + + libs = get_libs(tox_ini, MATCH_LIB_SENTRY_REGEX) + + for lib in libs: + print(".", end="") + sys.stdout.flush() + + # Get latest version of lib + url = PYPI_PROJECT_URL.format(project=lib) + pypi_data = requests.get(url) + + if pypi_data.status_code != 200: + not_found.add(lib) + continue + + latest_version = pypi_data.json()["info"]["version"] + + # Get supported Python version of latest version of lib + url = PYPI_PROJECT_URL.format(project=lib, version=latest_version) + pypi_data = requests.get(url) + + if pypi_data.status_code != 200: + continue + + classifiers = pypi_data.json()["info"]["classifiers"] + + if f"Programming Language :: Python :: {PYTHON_VERSION}" in classifiers: + ready.add(lib) + else: + not_ready.add(lib) + + # cut pypi some slack + time.sleep(0.1) + + # Print report + print("\n") + print(f"\nReady for Python {PYTHON_VERSION}:") + if len(ready) == 0: + print("- None ") + + for x in sorted(ready): + print(f"- {x}") + + print(f"\nNOT ready for Python {PYTHON_VERSION}:") + if len(not_ready) == 0: + print("- None ") + + for x in sorted(not_ready): + print(f"- {x}") + + print("\nNot found on PyPI:") + if len(not_found) == 0: + print("- None ") + + for x in sorted(not_found): + print(f"- {x}") + + +if __name__ == "__main__": + main() diff --git a/scripts/ready_yet/requirements.txt b/scripts/ready_yet/requirements.txt new file mode 100644 index 0000000000..e0590b89c6 --- /dev/null +++ b/scripts/ready_yet/requirements.txt @@ -0,0 +1,3 @@ +requests +pathlib +tox \ No newline at end of file diff --git a/scripts/ready_yet/run.sh b/scripts/ready_yet/run.sh new file mode 100755 index 0000000000..f32bd7bdda --- /dev/null +++ b/scripts/ready_yet/run.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# exit on first error +set -xe + +reset + +# create and activate virtual environment +python -m venv .venv +source .venv/bin/activate + +# Install (or update) requirements +python -m pip install -r requirements.txt + +# Run the script +python main.py \ No newline at end of file diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index a95bcaac71..882d74d6d2 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -6,6 +6,9 @@ # up top to prevent circular import due to integration import DEFAULT_MAX_VALUE_LENGTH = 1024 +DEFAULT_MAX_STACK_FRAMES = 100 +DEFAULT_ADD_FULL_STACK = False + # Also needs to be at the top to prevent circular import class EndpointType(Enum): @@ -537,6 +540,8 @@ def __init__( cert_file=None, # type: Optional[str] key_file=None, # type: Optional[str] custom_repr=None, # type: Optional[Callable[..., Optional[str]]] + add_full_stack=DEFAULT_ADD_FULL_STACK, # type: bool + max_stack_frames=DEFAULT_MAX_STACK_FRAMES, # type: Optional[int] ): # type: (...) -> None pass @@ -562,4 +567,4 @@ def _get_default_options(): del _get_default_options -VERSION = "2.19.0" +VERSION = "2.19.1" diff --git a/sentry_sdk/integrations/django/caching.py b/sentry_sdk/integrations/django/caching.py index b07bd4e9cf..562aec6e02 100644 --- a/sentry_sdk/integrations/django/caching.py +++ b/sentry_sdk/integrations/django/caching.py @@ -76,11 +76,12 @@ def _instrument_call( span.set_data(SPANDATA.CACHE_HIT, True) else: span.set_data(SPANDATA.CACHE_HIT, False) - else: - try: + else: # TODO: We don't handle `get_or_set` which we should + arg_count = len(args) + if arg_count >= 2: # 'set' command item_size = len(str(args[1])) - except IndexError: + elif arg_count == 1: # 'set_many' command item_size = len(str(args[0])) diff --git a/sentry_sdk/integrations/grpc/aio/client.py b/sentry_sdk/integrations/grpc/aio/client.py index d1f4b352d3..1a7086c55d 100644 --- a/sentry_sdk/integrations/grpc/aio/client.py +++ b/sentry_sdk/integrations/grpc/aio/client.py @@ -6,6 +6,7 @@ ClientCallDetails, UnaryUnaryCall, UnaryStreamCall, + Metadata, ) from google.protobuf.message import Message @@ -19,23 +20,19 @@ class ClientInterceptor: def _update_client_call_details_metadata_from_scope( client_call_details: ClientCallDetails, ) -> ClientCallDetails: - metadata = ( - list(client_call_details.metadata) if client_call_details.metadata else [] - ) + if client_call_details.metadata is None: + client_call_details = client_call_details._replace(metadata=Metadata()) + elif not isinstance(client_call_details.metadata, Metadata): + # This is a workaround for a GRPC bug, which was fixed in grpcio v1.60.0 + # See https://github.com/grpc/grpc/issues/34298. + client_call_details = client_call_details._replace( + metadata=Metadata.from_tuple(client_call_details.metadata) + ) for ( key, value, ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(): - metadata.append((key, value)) - - client_call_details = ClientCallDetails( - method=client_call_details.method, - timeout=client_call_details.timeout, - metadata=metadata, - credentials=client_call_details.credentials, - wait_for_ready=client_call_details.wait_for_ready, - ) - + client_call_details.metadata.add(key, value) return client_call_details diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 7083c3709c..a643cfa9c5 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -203,6 +203,7 @@ def __copy__(self): rv = object.__new__(self.__class__) # type: Scope rv._type = self._type + rv.client = self.client rv._level = self._level rv._name = self._name rv._fingerprint = self._fingerprint diff --git a/sentry_sdk/spotlight.py b/sentry_sdk/spotlight.py index 806ba5a09e..a94c691723 100644 --- a/sentry_sdk/spotlight.py +++ b/sentry_sdk/spotlight.py @@ -42,11 +42,6 @@ def __init__(self, url): def capture_envelope(self, envelope): # type: (Envelope) -> None - if self.tries > 3: - sentry_logger.warning( - "Too many errors sending to Spotlight, stop sending events there." - ) - return body = io.BytesIO() envelope.serialize_into(body) try: @@ -60,7 +55,7 @@ def capture_envelope(self, envelope): ) req.close() except Exception as e: - self.tries += 1 + # TODO: Implement buffering and retrying with exponential backoff sentry_logger.warning(str(e)) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 61ba34d956..8b45f555ae 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -25,7 +25,12 @@ BaseExceptionGroup = None # type: ignore import sentry_sdk -from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH, EndpointType +from sentry_sdk.consts import ( + DEFAULT_ADD_FULL_STACK, + DEFAULT_MAX_STACK_FRAMES, + DEFAULT_MAX_VALUE_LENGTH, + EndpointType, +) from typing import TYPE_CHECKING @@ -712,6 +717,7 @@ def single_exception_from_error_tuple( exception_id=None, # type: Optional[int] parent_id=None, # type: Optional[int] source=None, # type: Optional[str] + full_stack=None, # type: Optional[list[dict[str, Any]]] ): # type: (...) -> Dict[str, Any] """ @@ -779,10 +785,15 @@ def single_exception_from_error_tuple( custom_repr=custom_repr, ) for tb in iter_stacks(tb) - ] + ] # type: List[Dict[str, Any]] if frames: - exception_value["stacktrace"] = {"frames": frames} + if not full_stack: + new_frames = frames + else: + new_frames = merge_stack_frames(frames, full_stack, client_options) + + exception_value["stacktrace"] = {"frames": new_frames} return exception_value @@ -837,6 +848,7 @@ def exceptions_from_error( exception_id=0, # type: int parent_id=0, # type: int source=None, # type: Optional[str] + full_stack=None, # type: Optional[list[dict[str, Any]]] ): # type: (...) -> Tuple[int, List[Dict[str, Any]]] """ @@ -856,6 +868,7 @@ def exceptions_from_error( exception_id=exception_id, parent_id=parent_id, source=source, + full_stack=full_stack, ) exceptions = [parent] @@ -881,6 +894,7 @@ def exceptions_from_error( mechanism=mechanism, exception_id=exception_id, source="__cause__", + full_stack=full_stack, ) exceptions.extend(child_exceptions) @@ -902,6 +916,7 @@ def exceptions_from_error( mechanism=mechanism, exception_id=exception_id, source="__context__", + full_stack=full_stack, ) exceptions.extend(child_exceptions) @@ -918,6 +933,7 @@ def exceptions_from_error( exception_id=exception_id, parent_id=parent_id, source="exceptions[%s]" % idx, + full_stack=full_stack, ) exceptions.extend(child_exceptions) @@ -928,6 +944,7 @@ def exceptions_from_error_tuple( exc_info, # type: ExcInfo client_options=None, # type: Optional[Dict[str, Any]] mechanism=None, # type: Optional[Dict[str, Any]] + full_stack=None, # type: Optional[list[dict[str, Any]]] ): # type: (...) -> List[Dict[str, Any]] exc_type, exc_value, tb = exc_info @@ -945,6 +962,7 @@ def exceptions_from_error_tuple( mechanism=mechanism, exception_id=0, parent_id=0, + full_stack=full_stack, ) else: @@ -952,7 +970,12 @@ def exceptions_from_error_tuple( for exc_type, exc_value, tb in walk_exception_chain(exc_info): exceptions.append( single_exception_from_error_tuple( - exc_type, exc_value, tb, client_options, mechanism + exc_type=exc_type, + exc_value=exc_value, + tb=tb, + client_options=client_options, + mechanism=mechanism, + full_stack=full_stack, ) ) @@ -1071,6 +1094,46 @@ def exc_info_from_error(error): return exc_info +def merge_stack_frames(frames, full_stack, client_options): + # type: (List[Dict[str, Any]], List[Dict[str, Any]], Optional[Dict[str, Any]]) -> List[Dict[str, Any]] + """ + Add the missing frames from full_stack to frames and return the merged list. + """ + frame_ids = { + ( + frame["abs_path"], + frame["context_line"], + frame["lineno"], + frame["function"], + ) + for frame in frames + } + + new_frames = [ + stackframe + for stackframe in full_stack + if ( + stackframe["abs_path"], + stackframe["context_line"], + stackframe["lineno"], + stackframe["function"], + ) + not in frame_ids + ] + new_frames.extend(frames) + + # Limit the number of frames + max_stack_frames = ( + client_options.get("max_stack_frames", DEFAULT_MAX_STACK_FRAMES) + if client_options + else None + ) + if max_stack_frames is not None: + new_frames = new_frames[len(new_frames) - max_stack_frames :] + + return new_frames + + def event_from_exception( exc_info, # type: Union[BaseException, ExcInfo] client_options=None, # type: Optional[Dict[str, Any]] @@ -1079,12 +1142,21 @@ def event_from_exception( # type: (...) -> Tuple[Event, Dict[str, Any]] exc_info = exc_info_from_error(exc_info) hint = event_hint_with_exc_info(exc_info) + + if client_options and client_options.get("add_full_stack", DEFAULT_ADD_FULL_STACK): + full_stack = current_stacktrace( + include_local_variables=client_options["include_local_variables"], + max_value_length=client_options["max_value_length"], + )["frames"] + else: + full_stack = None + return ( { "level": "error", "exception": { "values": exceptions_from_error_tuple( - exc_info, client_options, mechanism + exc_info, client_options, mechanism, full_stack ) }, }, diff --git a/setup.py b/setup.py index db133fc75a..1e1dee8274 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.19.0", + version="2.19.1", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", diff --git a/tests/test_full_stack_frames.py b/tests/test_full_stack_frames.py new file mode 100644 index 0000000000..ad0826cd10 --- /dev/null +++ b/tests/test_full_stack_frames.py @@ -0,0 +1,103 @@ +import sentry_sdk + + +def test_full_stack_frames_default(sentry_init, capture_events): + sentry_init() + events = capture_events() + + def foo(): + try: + bar() + except Exception as e: + sentry_sdk.capture_exception(e) + + def bar(): + raise Exception("This is a test exception") + + foo() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + + assert len(frames) == 2 + assert frames[-1]["function"] == "bar" + assert frames[-2]["function"] == "foo" + + +def test_full_stack_frames_enabled(sentry_init, capture_events): + sentry_init( + add_full_stack=True, + ) + events = capture_events() + + def foo(): + try: + bar() + except Exception as e: + sentry_sdk.capture_exception(e) + + def bar(): + raise Exception("This is a test exception") + + foo() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + + assert len(frames) > 2 + assert frames[-1]["function"] == "bar" + assert frames[-2]["function"] == "foo" + assert frames[-3]["function"] == "foo" + assert frames[-4]["function"] == "test_full_stack_frames_enabled" + + +def test_full_stack_frames_enabled_truncated(sentry_init, capture_events): + sentry_init( + add_full_stack=True, + max_stack_frames=3, + ) + events = capture_events() + + def foo(): + try: + bar() + except Exception as e: + sentry_sdk.capture_exception(e) + + def bar(): + raise Exception("This is a test exception") + + foo() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + + assert len(frames) == 3 + assert frames[-1]["function"] == "bar" + assert frames[-2]["function"] == "foo" + assert frames[-3]["function"] == "foo" + + +def test_full_stack_frames_default_no_truncation_happening(sentry_init, capture_events): + sentry_init( + max_stack_frames=1, # this is ignored if add_full_stack=False (which is the default) + ) + events = capture_events() + + def foo(): + try: + bar() + except Exception as e: + sentry_sdk.capture_exception(e) + + def bar(): + raise Exception("This is a test exception") + + foo() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + + assert len(frames) == 2 + assert frames[-1]["function"] == "bar" + assert frames[-2]["function"] == "foo" diff --git a/tests/test_scope.py b/tests/test_scope.py index 308c7bd6c5..858295536c 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -23,10 +23,6 @@ ) -SLOTS_NOT_COPIED = {"client"} -"""__slots__ that are not copied when copying a Scope object.""" - - @pytest.fixture(autouse=True) def setup_otel_scope_management(): setup_scope_context_management() @@ -52,7 +48,7 @@ def test_all_slots_copied(): scope_copy = copy.copy(scope) # Check all attributes are copied - for attr in set(Scope.__slots__) - SLOTS_NOT_COPIED: + for attr in set(Scope.__slots__): assert getattr(scope_copy, attr) == getattr(scope, attr) diff --git a/tox.ini b/tox.ini index f76d19dc84..2bcf70a6bd 100644 --- a/tox.ini +++ b/tox.ini @@ -389,11 +389,9 @@ deps = {py3.7}-celery: importlib-metadata<5.0 # Chalice + chalice: pytest-chalice==0.0.5 chalice-v1.16: chalice~=1.16.0 chalice-latest: chalice - chalice: pytest-chalice==0.0.5 - - {py3.7,py3.8}-chalice: botocore~=1.31 # Clickhouse Driver clickhouse_driver-v0.2.0: clickhouse_driver~=0.2.0