diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..41dfc484ff --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ + + +--- + +## General Notes + +Thank you for contributing to `sentry-python`! + +Please add tests to validate your changes, and lint your code using `tox -e linters`. + +Running the test suite on your PR might require maintainer approval. Some tests (AWS Lambda) additionally require a maintainer to add a special label to run and will fail if the label is not present. + +#### For maintainers + +Sensitive test suites require maintainer review to ensure that tests do not compromise our secrets. This review must be repeated after any code revisions. + +Before running sensitive test suites, please carefully check the PR. Then, apply the `Trigger: tests using secrets` label. The label will be removed after any code changes to enforce our policy requiring maintainers to review all code revisions before running sensitive tests. diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 8aad751470..846fc0a7b6 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -43,7 +43,10 @@ from typing import Dict from typing import Optional from typing import Sequence + from typing import Type + from typing import Union + from sentry_sdk.integrations import Integration from sentry_sdk.scope import Scope from sentry_sdk._types import Event, Hint from sentry_sdk.session import Session @@ -653,6 +656,22 @@ def capture_session( else: self.session_flusher.add_session(session) + def get_integration( + self, name_or_class # type: Union[str, Type[Integration]] + ): + # type: (...) -> Any + """Returns the integration for this client by name or class. + If the client does not have that integration then `None` is returned. + """ + if isinstance(name_or_class, str): + integration_name = name_or_class + elif name_or_class.identifier is not None: + integration_name = name_or_class.identifier + else: + raise ValueError("Integration has no name") + + return self.integrations.get(integration_name) + def close( self, timeout=None, # type: Optional[float] diff --git a/sentry_sdk/hub.py b/sentry_sdk/hub.py index 2525dc56f1..032ccd09e7 100644 --- a/sentry_sdk/hub.py +++ b/sentry_sdk/hub.py @@ -3,7 +3,7 @@ from contextlib import contextmanager -from sentry_sdk._compat import datetime_utcnow, with_metaclass +from sentry_sdk._compat import with_metaclass from sentry_sdk.consts import INSTRUMENTER from sentry_sdk.scope import Scope from sentry_sdk.client import Client @@ -15,7 +15,6 @@ BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, ) -from sentry_sdk.session import Session from sentry_sdk.tracing_utils import ( has_tracing_enabled, normalize_incoming_data, @@ -294,18 +293,9 @@ def get_integration( If the return value is not `None` the hub is guaranteed to have a client attached. """ - if isinstance(name_or_class, str): - integration_name = name_or_class - elif name_or_class.identifier is not None: - integration_name = name_or_class.identifier - else: - raise ValueError("Integration has no name") - client = self.client if client is not None: - rv = client.integrations.get(integration_name) - if rv is not None: - return rv + return client.get_integration(name_or_class) @property def client(self): @@ -430,31 +420,9 @@ def add_breadcrumb(self, crumb=None, hint=None, **kwargs): logger.info("Dropped breadcrumb because no client bound") return - crumb = dict(crumb or ()) # type: Breadcrumb - crumb.update(kwargs) - if not crumb: - return - - hint = dict(hint or ()) # type: Hint - - if crumb.get("timestamp") is None: - crumb["timestamp"] = datetime_utcnow() - if crumb.get("type") is None: - crumb["type"] = "default" - - if client.options["before_breadcrumb"] is not None: - new_crumb = client.options["before_breadcrumb"](crumb, hint) - else: - new_crumb = crumb - - if new_crumb is not None: - scope._breadcrumbs.append(new_crumb) - else: - logger.info("before breadcrumb dropped breadcrumb (%s)", crumb) + kwargs["client"] = client - max_breadcrumbs = client.options["max_breadcrumbs"] # type: int - while len(scope._breadcrumbs) > max_breadcrumbs: - scope._breadcrumbs.popleft() + scope.add_breadcrumb(crumb, hint, **kwargs) def start_span(self, span=None, instrumenter=INSTRUMENTER.SENTRY, **kwargs): # type: (Optional[Span], str, Any) -> Span @@ -712,12 +680,9 @@ def start_session( ): # type: (...) -> None """Starts a new session.""" - self.end_session() client, scope = self._stack[-1] - scope._session = Session( - release=client.options["release"] if client else None, - environment=client.options["environment"] if client else None, - user=scope._user, + scope.start_session( + client=client, session_mode=session_mode, ) @@ -725,13 +690,7 @@ def end_session(self): # type: (...) -> None """Ends the current session if there is one.""" client, scope = self._stack[-1] - session = scope._session - self.scope._session = None - - if session is not None: - session.close() - if client is not None: - client.capture_session(session) + scope.end_session(client=client) def stop_auto_session_tracking(self): # type: (...) -> None @@ -740,9 +699,8 @@ def stop_auto_session_tracking(self): This temporarily session tracking for the current scope when called. To resume session tracking call `resume_auto_session_tracking`. """ - self.end_session() client, scope = self._stack[-1] - scope._force_auto_session_tracking = False + scope.stop_auto_session_tracking(client=client) def resume_auto_session_tracking(self): # type: (...) -> None @@ -750,8 +708,8 @@ def resume_auto_session_tracking(self): disabled earlier. This requires that generally automatic session tracking is enabled. """ - client, scope = self._stack[-1] - scope._force_auto_session_tracking = None + scope = self._stack[-1][1] + scope.resume_auto_session_tracking() def flush( self, diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 0ffdcf6de5..69902ca1a7 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -375,20 +375,22 @@ def add( def to_json(self): # type: (...) -> Dict[str, Any] - rv = {} + rv = {} # type: Any for (export_key, tags), ( v_min, v_max, v_count, v_sum, ) in self._measurements.items(): - rv[export_key] = { - "tags": _tags_to_dict(tags), - "min": v_min, - "max": v_max, - "count": v_count, - "sum": v_sum, - } + rv.setdefault(export_key, []).append( + { + "tags": _tags_to_dict(tags), + "min": v_min, + "max": v_max, + "count": v_count, + "sum": v_sum, + } + ) return rv diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 5096eccce0..8e9724b4c5 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -5,7 +5,10 @@ import uuid from sentry_sdk.attachments import Attachment +from sentry_sdk._compat import datetime_utcnow +from sentry_sdk.consts import FALSE_VALUES from sentry_sdk._functools import wraps +from sentry_sdk.session import Session from sentry_sdk.tracing_utils import ( Baggage, extract_sentrytrace_data, @@ -20,9 +23,6 @@ from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import logger, capture_internal_exceptions -from sentry_sdk.consts import FALSE_VALUES - - if TYPE_CHECKING: from typing import Any from typing import Dict @@ -36,6 +36,7 @@ from sentry_sdk._types import ( Breadcrumb, + BreadcrumbHint, Event, EventProcessor, ErrorProcessor, @@ -46,7 +47,6 @@ from sentry_sdk.profiler import Profile from sentry_sdk.tracing import Span - from sentry_sdk.session import Session F = TypeVar("F", bound=Callable[..., Any]) T = TypeVar("T") @@ -517,6 +517,97 @@ def add_attachment( ) ) + def add_breadcrumb(self, crumb=None, hint=None, **kwargs): + # type: (Optional[Breadcrumb], Optional[BreadcrumbHint], Any) -> None + """ + Adds a breadcrumb. + + :param crumb: Dictionary with the data as the sentry v7/v8 protocol expects. + + :param hint: An optional value that can be used by `before_breadcrumb` + to customize the breadcrumbs that are emitted. + """ + client = kwargs.pop("client", None) + if client is None: + return + + before_breadcrumb = client.options.get("before_breadcrumb") + max_breadcrumbs = client.options.get("max_breadcrumbs") + + crumb = dict(crumb or ()) # type: Breadcrumb + crumb.update(kwargs) + if not crumb: + return + + hint = dict(hint or ()) # type: Hint + + if crumb.get("timestamp") is None: + crumb["timestamp"] = datetime_utcnow() + if crumb.get("type") is None: + crumb["type"] = "default" + + if before_breadcrumb is not None: + new_crumb = before_breadcrumb(crumb, hint) + else: + new_crumb = crumb + + if new_crumb is not None: + self._breadcrumbs.append(new_crumb) + else: + logger.info("before breadcrumb dropped breadcrumb (%s)", crumb) + + while len(self._breadcrumbs) > max_breadcrumbs: + self._breadcrumbs.popleft() + + def start_session(self, *args, **kwargs): + # type: (*Any, **Any) -> None + """Starts a new session.""" + client = kwargs.pop("client", None) + session_mode = kwargs.pop("session_mode", "application") + + self.end_session(client=client) + + self._session = Session( + release=client.options["release"] if client else None, + environment=client.options["environment"] if client else None, + user=self._user, + session_mode=session_mode, + ) + + def end_session(self, *args, **kwargs): + # type: (*Any, **Any) -> None + """Ends the current session if there is one.""" + client = kwargs.pop("client", None) + + session = self._session + self._session = None + + if session is not None: + session.close() + if client is not None: + client.capture_session(session) + + def stop_auto_session_tracking(self, *args, **kwargs): + # type: (*Any, **Any) -> None + """Stops automatic session tracking. + + This temporarily session tracking for the current scope when called. + To resume session tracking call `resume_auto_session_tracking`. + """ + client = kwargs.pop("client", None) + + self.end_session(client=client) + + self._force_auto_session_tracking = False + + def resume_auto_session_tracking(self): + # type: (...) -> None + """Resumes automatic session tracking for the current scope if + disabled earlier. This requires that generally automatic session + tracking is enabled. + """ + self._force_auto_session_tracking = None + def add_event_processor( self, func # type: EventProcessor ): diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 3decca31c2..3f8b6049d8 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -597,33 +597,37 @@ def test_metric_summaries(sentry_init, capture_envelopes): t = transaction.items[0].get_transaction_event() assert t["_metrics_summary"] == { - "c:root-counter@none": { - "count": 1, - "min": 1.0, - "max": 1.0, - "sum": 1.0, + "c:root-counter@none": [ + { + "count": 1, + "min": 1.0, + "max": 1.0, + "sum": 1.0, + "tags": { + "transaction": "/foo", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + }, + } + ] + } + + assert t["spans"][0]["_metrics_summary"]["d:my-dist@none"] == [ + { + "count": 10, + "min": 0.0, + "max": 9.0, + "sum": 45.0, "tags": { - "transaction": "/foo", - "release": "fun-release@1.0.0", "environment": "not-fun-env", + "release": "fun-release@1.0.0", + "transaction": "/foo", }, } - } - - assert t["spans"][0]["_metrics_summary"]["d:my-dist@none"] == { - "count": 10, - "min": 0.0, - "max": 9.0, - "sum": 45.0, - "tags": { - "environment": "not-fun-env", - "release": "fun-release@1.0.0", - "transaction": "/foo", - }, - } + ] assert t["spans"][0]["tags"] == {"a": "b"} - timer = t["spans"][0]["_metrics_summary"]["d:my-timer-metric@second"] + (timer,) = t["spans"][0]["_metrics_summary"]["d:my-timer-metric@second"] assert timer["count"] == 1 assert timer["max"] == timer["min"] == timer["sum"] assert timer["sum"] > 0 @@ -697,6 +701,7 @@ def should_summarize_metric(key, tags): op="stuff", name="/foo", source=TRANSACTION_SOURCE_ROUTE ) as transaction: metrics.timing("foo", value=1.0, tags={"a": "b"}, timestamp=ts) + metrics.timing("foo", value=1.0, tags={"b": "c"}, timestamp=ts) metrics.timing("bar", value=1.0, tags={"a": "b"}, timestamp=ts) Hub.current.flush() @@ -707,25 +712,40 @@ def should_summarize_metric(key, tags): assert envelope.items[0].headers["type"] == "statsd" m = parse_metrics(envelope.items[0].payload.get_bytes()) - assert len(m) == 2 + assert len(m) == 3 assert m[0][1] == "bar@second" assert m[1][1] == "foo@second" + assert m[2][1] == "foo@second" # Measurement Attachment t = transaction.items[0].get_transaction_event()["_metrics_summary"] assert t == { - "d:foo@second": { - "tags": { - "a": "b", - "environment": "not-fun-env", - "release": "fun-release@1.0.0", - "transaction": "/foo", + "d:foo@second": [ + { + "tags": { + "a": "b", + "environment": "not-fun-env", + "release": "fun-release@1.0.0", + "transaction": "/foo", + }, + "min": 1.0, + "max": 1.0, + "count": 1, + "sum": 1.0, }, - "min": 1.0, - "max": 1.0, - "count": 1, - "sum": 1.0, - } + { + "tags": { + "b": "c", + "environment": "not-fun-env", + "release": "fun-release@1.0.0", + "transaction": "/foo", + }, + "min": 1.0, + "max": 1.0, + "count": 1, + "sum": 1.0, + }, + ] } diff --git a/tox.ini b/tox.ini index 46477750e9..d93bc8ee1d 100644 --- a/tox.ini +++ b/tox.ini @@ -241,7 +241,7 @@ deps = linters: werkzeug<2.3.0 # Common - {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12}-common: pytest-asyncio + {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12}-common: pytest-asyncio<=0.21.1 # See https://github.com/pytest-dev/pytest/issues/9621 # and https://github.com/pytest-dev/pytest-forked/issues/67 # for justification of the upper bound on pytest @@ -252,6 +252,8 @@ deps = aiohttp-v3.8: aiohttp~=3.8.0 aiohttp-latest: aiohttp aiohttp: pytest-aiohttp + aiohttp-v3.8: pytest-asyncio<=0.21.1 + aiohttp-latest: pytest-asyncio<=0.21.1 # Ariadne ariadne-v0.20: ariadne~=0.20.0 @@ -265,17 +267,17 @@ deps = arq-v0.23: pydantic<2 arq-latest: arq arq: fakeredis>=2.2.0,<2.8 - arq: pytest-asyncio + arq: pytest-asyncio<=0.21.1 arq: async-timeout # Asgi - asgi: pytest-asyncio + asgi: pytest-asyncio<=0.21.1 asgi: async-asgi-testclient # Asyncpg asyncpg-v0.23: asyncpg~=0.23.0 asyncpg-latest: asyncpg - asyncpg: pytest-asyncio + asyncpg: pytest-asyncio<=0.21.1 # AWS Lambda aws_lambda: boto3 @@ -329,10 +331,10 @@ deps = django-v{1.8,1.11,2.0}: pytest-django<4.0 django-v{2.2,3.0,3.2,4.0,4.1,4.2,5.0}: pytest-django django-v{4.0,4.1,4.2,5.0}: djangorestframework - django-v{4.0,4.1,4.2,5.0}: pytest-asyncio + django-v{4.0,4.1,4.2,5.0}: pytest-asyncio<=0.21.1 django-v{4.0,4.1,4.2,5.0}: Werkzeug django-latest: djangorestframework - django-latest: pytest-asyncio + django-latest: pytest-asyncio<=0.21.1 django-latest: pytest-django django-latest: Werkzeug django-latest: channels[daphne] @@ -346,8 +348,7 @@ deps = django-v4.0: Django~=4.0.0 django-v4.1: Django~=4.1.0 django-v4.2: Django~=4.2.0 - # TODO: change to final when available - django-v5.0: Django==5.0rc1 + django-v5.0: Django~=5.0.0 django-latest: Django # Falcon @@ -360,7 +361,7 @@ deps = # FastAPI fastapi: httpx fastapi: anyio<4.0.0 # thats a dep of httpx - fastapi: pytest-asyncio + fastapi: pytest-asyncio<=0.21.1 fastapi: python-multipart fastapi: requests fastapi-v{0.79}: fastapi~=0.79.0 @@ -407,7 +408,7 @@ deps = grpc: protobuf grpc: mypy-protobuf grpc: types-protobuf - grpc: pytest-asyncio + grpc: pytest-asyncio<=0.21.1 grpc-v1.21: grpcio-tools~=1.21.0 grpc-v1.30: grpcio-tools~=1.30.0 grpc-v1.40: grpcio-tools~=1.40.0 @@ -466,7 +467,7 @@ deps = # Quart quart: quart-auth - quart: pytest-asyncio + quart: pytest-asyncio<=0.21.1 quart-v0.16: blinker<1.6 quart-v0.16: jinja2<3.1.0 quart-v0.16: Werkzeug<2.1.0 @@ -478,7 +479,7 @@ deps = # Redis redis: fakeredis!=1.7.4 - {py3.7,py3.8,py3.9,py3.10,py3.11}-redis: pytest-asyncio + {py3.7,py3.8,py3.9,py3.10,py3.11}-redis: pytest-asyncio<=0.21.1 redis-v3: redis~=3.0 redis-v4: redis~=4.0 redis-v5: redis~=5.0 @@ -520,7 +521,7 @@ deps = sanic-latest: sanic # Starlette - starlette: pytest-asyncio + starlette: pytest-asyncio<=0.21.1 starlette: python-multipart starlette: requests starlette: httpx @@ -534,7 +535,7 @@ deps = starlette-latest: starlette # Starlite - starlite: pytest-asyncio + starlite: pytest-asyncio<=0.21.1 starlite: python-multipart starlite: requests starlite: cryptography