From ed3ac886b6ae66f2bfb689d0f5222ebc57d41e4f Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Thu, 25 Jan 2024 10:56:01 +0100 Subject: [PATCH] Cleaning up existing code to prepare for new Scopes API (#2611) This cleans up existing code and reorganizes it to have a clean foundation for the refactoring the Hub and Scopes. It moves functionality away from the Hub into the Scope respectively the Client. --- docs/apidocs.rst | 3 + sentry_sdk/api.py | 12 +- sentry_sdk/client.py | 25 +- sentry_sdk/hub.py | 351 +++++++---------------- sentry_sdk/scope.py | 492 ++++++++++++++++++++++++++++++-- sentry_sdk/utils.py | 50 +++- tests/test_client.py | 33 ++- tests/utils/test_contextvars.py | 2 +- 8 files changed, 666 insertions(+), 302 deletions(-) diff --git a/docs/apidocs.rst b/docs/apidocs.rst index dc4117e559..855778484d 100644 --- a/docs/apidocs.rst +++ b/docs/apidocs.rst @@ -11,6 +11,9 @@ API Docs .. autoclass:: sentry_sdk.Client :members: +.. autoclass:: sentry_sdk.client._Client + :members: + .. autoclass:: sentry_sdk.Transport :members: diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index f0c6a87432..ffa525ca66 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -82,10 +82,10 @@ def capture_event( event, # type: Event hint=None, # type: Optional[Hint] scope=None, # type: Optional[Any] - **scope_args # type: Any + **scope_kwargs # type: Any ): # type: (...) -> Optional[str] - return Hub.current.capture_event(event, hint, scope=scope, **scope_args) + return Hub.current.capture_event(event, hint, scope=scope, **scope_kwargs) @hubmethod @@ -93,20 +93,20 @@ def capture_message( message, # type: str level=None, # type: Optional[str] scope=None, # type: Optional[Any] - **scope_args # type: Any + **scope_kwargs # type: Any ): # type: (...) -> Optional[str] - return Hub.current.capture_message(message, level, scope=scope, **scope_args) + return Hub.current.capture_message(message, level, scope=scope, **scope_kwargs) @hubmethod def capture_exception( error=None, # type: Optional[Union[BaseException, ExcInfo]] scope=None, # type: Optional[Any] - **scope_args # type: Any + **scope_kwargs # type: Any ): # type: (...) -> Optional[str] - return Hub.current.capture_exception(error, scope=scope, **scope_args) + return Hub.current.capture_exception(error, scope=scope, **scope_kwargs) @hubmethod diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 3ce4b30606..4c7077e1cc 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 @@ -153,6 +156,8 @@ class _Client(object): forwarding them to sentry through the configured transport. It takes the client options as keyword arguments and optionally the DSN as first argument. + + Alias of :py:class:`Client`. (Was created for better intelisense support) """ def __init__(self, *args, **kwargs): @@ -563,8 +568,8 @@ def capture_event( :param hint: Contains metadata about the event that can be read from `before_send`, such as the original exception object or a HTTP request object. - :param scope: An optional scope to use for determining whether this event - should be captured. + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. :returns: An event ID. May be `None` if there is no DSN set or of if the SDK decided to discard the event for other reasons. In such situations setting `debug=True` on `init()` may help. """ @@ -667,6 +672,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..45afb56cc9 100644 --- a/sentry_sdk/hub.py +++ b/sentry_sdk/hub.py @@ -3,27 +3,17 @@ 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 -from sentry_sdk.profiler import Profile from sentry_sdk.tracing import ( NoOpSpan, Span, Transaction, - 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, ) from sentry_sdk.utils import ( - exc_info_from_error, - event_from_exception, logger, ContextVar, ) @@ -31,18 +21,18 @@ from sentry_sdk._types import TYPE_CHECKING if TYPE_CHECKING: - from typing import Union from typing import Any - from typing import Optional - from typing import Tuple - from typing import Dict - from typing import List from typing import Callable + from typing import ContextManager + from typing import Dict from typing import Generator + from typing import List + from typing import Optional + from typing import overload + from typing import Tuple from typing import Type from typing import TypeVar - from typing import overload - from typing import ContextManager + from typing import Union from sentry_sdk.integrations import Integration from sentry_sdk._types import ( @@ -66,24 +56,6 @@ def overload(x): _local = ContextVar("sentry_current_hub") -def _update_scope(base, scope_change, scope_kwargs): - # type: (Scope, Optional[Any], Dict[str, Any]) -> Scope - if scope_change and scope_kwargs: - raise TypeError("cannot provide scope and kwargs") - if scope_change is not None: - final_scope = copy.copy(base) - if callable(scope_change): - scope_change(final_scope) - else: - final_scope.update_from_scope(scope_change) - elif scope_kwargs: - final_scope = copy.copy(base) - final_scope.update_from_kwargs(**scope_kwargs) - else: - final_scope = base - return final_scope - - def _should_send_default_pii(): # type: () -> bool client = Hub.current.client @@ -294,18 +266,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): @@ -332,76 +295,100 @@ def bind_client( top = self._stack[-1] self._stack[-1] = (new, top[1]) - def capture_event(self, event, hint=None, scope=None, **scope_args): + def capture_event(self, event, hint=None, scope=None, **scope_kwargs): # type: (Event, Optional[Hint], Optional[Scope], Any) -> Optional[str] """ Captures an event. - Alias of :py:meth:`sentry_sdk.Client.capture_event`. + Alias of :py:meth:`sentry_sdk.Scope.capture_event`. + + :param event: A ready-made event that can be directly sent to Sentry. + + :param hint: Contains metadata about the event that can be read from `before_send`, such as the original exception object or a HTTP request object. - :param scope_args: For supported `**scope_args` see - :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :param scope_kwargs: Optional data to apply to event. + For supported `**scope_kwargs` see :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + The `scope` and `scope_kwargs` parameters are mutually exclusive. """ client, top_scope = self._stack[-1] - scope = _update_scope(top_scope, scope, scope_args) - if client is not None: - is_transaction = event.get("type") == "transaction" - rv = client.capture_event(event, hint, scope) - if rv is not None and not is_transaction: - self._last_event_id = rv - return rv - return None + if client is None: + return None - def capture_message(self, message, level=None, scope=None, **scope_args): + last_event_id = top_scope.capture_event( + event, hint, client=client, scope=scope, **scope_kwargs + ) + + is_transaction = event.get("type") == "transaction" + if last_event_id is not None and not is_transaction: + self._last_event_id = last_event_id + + return last_event_id + + def capture_message(self, message, level=None, scope=None, **scope_kwargs): # type: (str, Optional[str], Optional[Scope], Any) -> Optional[str] """ Captures a message. - :param message: The string to send as the message. + Alias of :py:meth:`sentry_sdk.Scope.capture_message`. + + :param message: The string to send as the message to Sentry. :param level: If no level is provided, the default level is `info`. - :param scope: An optional :py:class:`sentry_sdk.Scope` to use. + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. - :param scope_args: For supported `**scope_args` see - :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + :param scope_kwargs: Optional data to apply to event. + For supported `**scope_kwargs` see :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + The `scope` and `scope_kwargs` parameters are mutually exclusive. :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.Client.capture_event`). """ - if self.client is None: + client, top_scope = self._stack[-1] + if client is None: return None - if level is None: - level = "info" - return self.capture_event( - {"message": message, "level": level}, scope=scope, **scope_args + + last_event_id = top_scope.capture_message( + message, level=level, client=client, scope=scope, **scope_kwargs ) - def capture_exception(self, error=None, scope=None, **scope_args): + if last_event_id is not None: + self._last_event_id = last_event_id + + return last_event_id + + def capture_exception(self, error=None, scope=None, **scope_kwargs): # type: (Optional[Union[BaseException, ExcInfo]], Optional[Scope], Any) -> Optional[str] """Captures an exception. - :param error: An exception to catch. If `None`, `sys.exc_info()` will be used. + Alias of :py:meth:`sentry_sdk.Scope.capture_exception`. + + :param error: An exception to capture. If `None`, `sys.exc_info()` will be used. + + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. - :param scope_args: For supported `**scope_args` see - :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + :param scope_kwargs: Optional data to apply to event. + For supported `**scope_kwargs` see :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + The `scope` and `scope_kwargs` parameters are mutually exclusive. :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.Client.capture_event`). """ - client = self.client + client, top_scope = self._stack[-1] if client is None: return None - if error is not None: - exc_info = exc_info_from_error(error) - else: - exc_info = sys.exc_info() - event, hint = event_from_exception(exc_info, client_options=client.options) - try: - return self.capture_event(event, hint=hint, scope=scope, **scope_args) - except Exception: - self._capture_internal_exception(sys.exc_info()) + last_event_id = top_scope.capture_exception( + error, client=client, scope=scope, **scope_kwargs + ) - return None + if last_event_id is not None: + self._last_event_id = last_event_id + + return last_event_id def _capture_internal_exception( self, exc_info # type: Any @@ -411,6 +398,8 @@ def _capture_internal_exception( Capture an exception that is likely caused by a bug in the SDK itself. + Duplicated in :py:meth:`sentry_sdk.Client._capture_internal_exception`. + These exceptions do not end up in Sentry and are just logged instead. """ logger.error("Internal error in sentry_sdk", exc_info=exc_info) @@ -430,31 +419,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" + kwargs["client"] = client - 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) - - 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 @@ -473,54 +440,12 @@ def start_span(self, span=None, instrumenter=INSTRUMENTER.SENTRY, **kwargs): For supported `**kwargs` see :py:class:`sentry_sdk.tracing.Span`. """ - configuration_instrumenter = self.client and self.client.options["instrumenter"] - - if instrumenter != configuration_instrumenter: - return NoOpSpan() - - # THIS BLOCK IS DEPRECATED - # TODO: consider removing this in a future release. - # This is for backwards compatibility with releases before - # start_transaction existed, to allow for a smoother transition. - if isinstance(span, Transaction) or "transaction" in kwargs: - deprecation_msg = ( - "Deprecated: use start_transaction to start transactions and " - "Transaction.start_child to start spans." - ) - - if isinstance(span, Transaction): - logger.warning(deprecation_msg) - return self.start_transaction(span) - - if "transaction" in kwargs: - logger.warning(deprecation_msg) - name = kwargs.pop("transaction") - return self.start_transaction(name=name, **kwargs) - - # THIS BLOCK IS DEPRECATED - # We do not pass a span into start_span in our code base, so I deprecate this. - if span is not None: - deprecation_msg = "Deprecated: passing a span into `start_span` is deprecated and will be removed in the future." - logger.warning(deprecation_msg) - return span - - kwargs.setdefault("hub", self) - - active_span = self.scope.span - if active_span is not None: - new_child_span = active_span.start_child(**kwargs) - return new_child_span + client, scope = self._stack[-1] - # If there is already a trace_id in the propagation context, use it. - # This does not need to be done for `start_child` above because it takes - # the trace_id from the parent span. - if "trace_id" not in kwargs: - traceparent = self.get_traceparent() - trace_id = traceparent.split("-")[0] if traceparent else None - if trace_id is not None: - kwargs["trace_id"] = trace_id + kwargs["hub"] = self + kwargs["client"] = client - return Span(**kwargs) + return scope.start_span(span=span, instrumenter=instrumenter, **kwargs) def start_transaction( self, transaction=None, instrumenter=INSTRUMENTER.SENTRY, **kwargs @@ -550,55 +475,25 @@ def start_transaction( For supported `**kwargs` see :py:class:`sentry_sdk.tracing.Transaction`. """ - configuration_instrumenter = self.client and self.client.options["instrumenter"] - - if instrumenter != configuration_instrumenter: - return NoOpSpan() - - custom_sampling_context = kwargs.pop("custom_sampling_context", {}) - - # if we haven't been given a transaction, make one - if transaction is None: - kwargs.setdefault("hub", self) - transaction = Transaction(**kwargs) - - # use traces_sample_rate, traces_sampler, and/or inheritance to make a - # sampling decision - sampling_context = { - "transaction_context": transaction.to_json(), - "parent_sampled": transaction.parent_sampled, - } - sampling_context.update(custom_sampling_context) - transaction._set_initial_sampling_decision(sampling_context=sampling_context) - - profile = Profile(transaction, hub=self) - profile._set_initial_sampling_decision(sampling_context=sampling_context) + client, scope = self._stack[-1] - # we don't bother to keep spans if we already know we're not going to - # send the transaction - if transaction.sampled: - max_spans = ( - self.client and self.client.options["_experiments"].get("max_spans") - ) or 1000 - transaction.init_span_recorder(maxlen=max_spans) + kwargs["hub"] = self + kwargs["client"] = client - return transaction + return scope.start_transaction( + transaction=transaction, instrumenter=instrumenter, **kwargs + ) def continue_trace(self, environ_or_headers, op=None, name=None, source=None): # type: (Dict[str, Any], Optional[str], Optional[str], Optional[str]) -> Transaction """ Sets the propagation context from environment or headers and returns a transaction. """ - with self.configure_scope() as scope: - scope.generate_propagation_context(environ_or_headers) + scope = self._stack[-1][1] - transaction = Transaction.continue_from_headers( - normalize_incoming_data(environ_or_headers), - op=op, - name=name, - source=source, + return scope.continue_trace( + environ_or_headers=environ_or_headers, op=op, name=name, source=source ) - return transaction @overload def push_scope( @@ -712,12 +607,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 +617,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 +626,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 +635,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, @@ -771,25 +656,16 @@ def get_traceparent(self): """ Returns the traceparent either from the active span or from the scope. """ - if self.client is not None: - if has_tracing_enabled(self.client.options) and self.scope.span is not None: - return self.scope.span.to_traceparent() - - return self.scope.get_traceparent() + client, scope = self._stack[-1] + return scope.get_traceparent(client=client) def get_baggage(self): # type: () -> Optional[str] """ Returns Baggage either from the active span or from the scope. """ - if ( - self.client is not None - and has_tracing_enabled(self.client.options) - and self.scope.span is not None - ): - baggage = self.scope.span.to_baggage() - else: - baggage = self.scope.get_baggage() + client, scope = self._stack[-1] + baggage = scope.get_baggage(client=client) if baggage is not None: return baggage.serialize() @@ -803,19 +679,9 @@ def iter_trace_propagation_headers(self, span=None): from the span representing the request, if available, or the current span on the scope if not. """ - client = self._stack[-1][0] - propagate_traces = client and client.options["propagate_traces"] - if not propagate_traces: - return - - span = span or self.scope.span + client, scope = self._stack[-1] - if client and has_tracing_enabled(client.options) and span is not None: - for header in span.iter_headers(): - yield header - else: - for header in self.scope.iter_headers(): - yield header + return scope.iter_trace_propagation_headers(span=span, client=client) def trace_propagation_meta(self, span=None): # type: (Optional[Span]) -> str @@ -828,23 +694,8 @@ def trace_propagation_meta(self, span=None): "The parameter `span` in trace_propagation_meta() is deprecated and will be removed in the future." ) - meta = "" - - sentry_trace = self.get_traceparent() - if sentry_trace is not None: - meta += '' % ( - SENTRY_TRACE_HEADER_NAME, - sentry_trace, - ) - - baggage = self.get_baggage() - if baggage is not None: - meta += '' % ( - BAGGAGE_HEADER_NAME, - baggage, - ) - - return meta + client, scope = self._stack[-1] + return scope.trace_propagation_meta(span=span, client=client) GLOBAL_HUB = Hub() diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 5096eccce0..7678def407 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -2,10 +2,15 @@ from collections import deque from itertools import chain import os +import sys import uuid from sentry_sdk.attachments import Attachment +from sentry_sdk._compat import datetime_utcnow +from sentry_sdk.consts import FALSE_VALUES, INSTRUMENTER from sentry_sdk._functools import wraps +from sentry_sdk.profiler import Profile +from sentry_sdk.session import Session from sentry_sdk.tracing_utils import ( Baggage, extract_sentrytrace_data, @@ -15,38 +20,43 @@ from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, + NoOpSpan, + Span, Transaction, ) from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.utils import logger, capture_internal_exceptions - -from sentry_sdk.consts import FALSE_VALUES - +from sentry_sdk.utils import ( + event_from_exception, + exc_info_from_error, + logger, + capture_internal_exceptions, +) if TYPE_CHECKING: from typing import Any + from typing import Callable + from typing import Deque from typing import Dict + from typing import Generator from typing import Iterator - from typing import Optional - from typing import Deque from typing import List - from typing import Callable + from typing import Optional from typing import Tuple from typing import TypeVar + from typing import Union from sentry_sdk._types import ( Breadcrumb, + BreadcrumbHint, + ErrorProcessor, Event, EventProcessor, - ErrorProcessor, ExcInfo, Hint, Type, ) - from sentry_sdk.profiler import Profile - from sentry_sdk.tracing import Span - from sentry_sdk.session import Session + import sentry_sdk F = TypeVar("F", bound=Callable[..., Any]) T = TypeVar("T") @@ -81,6 +91,28 @@ def wrapper(self, *args, **kwargs): return wrapper # type: ignore +def _merge_scopes(base, scope_change, scope_kwargs): + # type: (Scope, Optional[Any], Dict[str, Any]) -> Scope + if scope_change and scope_kwargs: + raise TypeError("cannot provide scope and kwargs") + + if scope_change is not None: + final_scope = copy(base) + if callable(scope_change): + scope_change(final_scope) + else: + final_scope.update_from_scope(scope_change) + + elif scope_kwargs: + final_scope = copy(base) + final_scope.update_from_kwargs(**scope_kwargs) + + else: + final_scope = base + + return final_scope + + class Scope(object): """The scope holds extra information that should be sent with all events that belong to it. @@ -244,11 +276,22 @@ def get_dynamic_sampling_context(self): return self._propagation_context["dynamic_sampling_context"] - def get_traceparent(self): - # type: () -> Optional[str] + def get_traceparent(self, *args, **kwargs): + # type: (Any, Any) -> Optional[str] """ - Returns the Sentry "sentry-trace" header (aka the traceparent) from the Propagation Context. + Returns the Sentry "sentry-trace" header (aka the traceparent) from the + currently active span or the scopes Propagation Context. """ + client = kwargs.pop("client", None) + + # If we have an active span, return traceparent from there + if ( + client is not None + and has_tracing_enabled(client.options) + and self.span is not None + ): + return self.span.to_traceparent() + if self._propagation_context is None: return None @@ -258,8 +301,18 @@ def get_traceparent(self): ) return traceparent - def get_baggage(self): - # type: () -> Optional[Baggage] + def get_baggage(self, *args, **kwargs): + # type: (Any, Any) -> Optional[Baggage] + client = kwargs.pop("client", None) + + # If we have an active span, return baggage from there + if ( + client is not None + and has_tracing_enabled(client.options) + and self.span is not None + ): + return self.span.to_baggage() + if self._propagation_context is None: return None @@ -288,6 +341,38 @@ def get_trace_context(self): return trace_context + def trace_propagation_meta(self, *args, **kwargs): + # type: (*Any, **Any) -> str + """ + Return meta tags which should be injected into HTML templates + to allow propagation of trace information. + """ + span = kwargs.pop("span", None) + if span is not None: + logger.warning( + "The parameter `span` in trace_propagation_meta() is deprecated and will be removed in the future." + ) + + client = kwargs.pop("client", None) + + meta = "" + + sentry_trace = self.get_traceparent(client=client) + if sentry_trace is not None: + meta += '' % ( + SENTRY_TRACE_HEADER_NAME, + sentry_trace, + ) + + baggage = self.get_baggage(client=client) + if baggage is not None: + meta += '' % ( + BAGGAGE_HEADER_NAME, + baggage.serialize(), + ) + + return meta + def iter_headers(self): # type: () -> Iterator[Tuple[str, str]] """ @@ -303,6 +388,29 @@ def iter_headers(self): baggage = Baggage(dsc).serialize() yield BAGGAGE_HEADER_NAME, baggage + def iter_trace_propagation_headers(self, *args, **kwargs): + # type: (Any, Any) -> Generator[Tuple[str, str], None, None] + """ + Return HTTP headers which allow propagation of trace data. Data taken + from the span representing the request, if available, or the current + span on the scope if not. + """ + span = kwargs.pop("span", None) + client = kwargs.pop("client", None) + + propagate_traces = client and client.options["propagate_traces"] + if not propagate_traces: + return + + span = span or self.span + + if client and has_tracing_enabled(client.options) and span is not None: + for header in span.iter_headers(): + yield header + else: + for header in self.iter_headers(): + yield header + def clear(self): # type: () -> None """Clears the entire scope.""" @@ -517,6 +625,358 @@ 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_transaction( + self, transaction=None, instrumenter=INSTRUMENTER.SENTRY, **kwargs + ): + # type: (Optional[Transaction], str, Any) -> Union[Transaction, NoOpSpan] + """ + Start and return a transaction. + + Start an existing transaction if given, otherwise create and start a new + transaction with kwargs. + + This is the entry point to manual tracing instrumentation. + + A tree structure can be built by adding child spans to the transaction, + and child spans to other spans. To start a new child span within the + transaction or any span, call the respective `.start_child()` method. + + Every child span must be finished before the transaction is finished, + otherwise the unfinished spans are discarded. + + When used as context managers, spans and transactions are automatically + finished at the end of the `with` block. If not using context managers, + call the `.finish()` method. + + When the transaction is finished, it will be sent to Sentry with all its + finished child spans. + + For supported `**kwargs` see :py:class:`sentry_sdk.tracing.Transaction`. + """ + hub = kwargs.pop("hub", None) + client = kwargs.pop("client", None) + + configuration_instrumenter = client and client.options["instrumenter"] + + if instrumenter != configuration_instrumenter: + return NoOpSpan() + + custom_sampling_context = kwargs.pop("custom_sampling_context", {}) + + # if we haven't been given a transaction, make one + if transaction is None: + kwargs.setdefault("hub", hub) + transaction = Transaction(**kwargs) + + # use traces_sample_rate, traces_sampler, and/or inheritance to make a + # sampling decision + sampling_context = { + "transaction_context": transaction.to_json(), + "parent_sampled": transaction.parent_sampled, + } + sampling_context.update(custom_sampling_context) + transaction._set_initial_sampling_decision(sampling_context=sampling_context) + + profile = Profile(transaction, hub=hub) + profile._set_initial_sampling_decision(sampling_context=sampling_context) + + # we don't bother to keep spans if we already know we're not going to + # send the transaction + if transaction.sampled: + max_spans = ( + client and client.options["_experiments"].get("max_spans") + ) or 1000 + transaction.init_span_recorder(maxlen=max_spans) + + return transaction + + def start_span(self, span=None, instrumenter=INSTRUMENTER.SENTRY, **kwargs): + # type: (Optional[Span], str, Any) -> Span + """ + Start a span whose parent is the currently active span or transaction, if any. + + The return value is a :py:class:`sentry_sdk.tracing.Span` instance, + typically used as a context manager to start and stop timing in a `with` + block. + + Only spans contained in a transaction are sent to Sentry. Most + integrations start a transaction at the appropriate time, for example + for every incoming HTTP request. Use + :py:meth:`sentry_sdk.start_transaction` to start a new transaction when + one is not already in progress. + + For supported `**kwargs` see :py:class:`sentry_sdk.tracing.Span`. + """ + client = kwargs.get("client", None) + + configuration_instrumenter = client and client.options["instrumenter"] + + if instrumenter != configuration_instrumenter: + return NoOpSpan() + + # THIS BLOCK IS DEPRECATED + # TODO: consider removing this in a future release. + # This is for backwards compatibility with releases before + # start_transaction existed, to allow for a smoother transition. + if isinstance(span, Transaction) or "transaction" in kwargs: + deprecation_msg = ( + "Deprecated: use start_transaction to start transactions and " + "Transaction.start_child to start spans." + ) + + if isinstance(span, Transaction): + logger.warning(deprecation_msg) + return self.start_transaction(span, **kwargs) + + if "transaction" in kwargs: + logger.warning(deprecation_msg) + name = kwargs.pop("transaction") + return self.start_transaction(name=name, **kwargs) + + # THIS BLOCK IS DEPRECATED + # We do not pass a span into start_span in our code base, so I deprecate this. + if span is not None: + deprecation_msg = "Deprecated: passing a span into `start_span` is deprecated and will be removed in the future." + logger.warning(deprecation_msg) + return span + + kwargs.pop("client") + + active_span = self.span + if active_span is not None: + new_child_span = active_span.start_child(**kwargs) + return new_child_span + + # If there is already a trace_id in the propagation context, use it. + # This does not need to be done for `start_child` above because it takes + # the trace_id from the parent span. + if "trace_id" not in kwargs: + traceparent = self.get_traceparent() + trace_id = traceparent.split("-")[0] if traceparent else None + if trace_id is not None: + kwargs["trace_id"] = trace_id + + return Span(**kwargs) + + def continue_trace(self, environ_or_headers, op=None, name=None, source=None): + # type: (Dict[str, Any], Optional[str], Optional[str], Optional[str]) -> Transaction + """ + Sets the propagation context from environment or headers and returns a transaction. + """ + self.generate_propagation_context(environ_or_headers) + + transaction = Transaction.continue_from_headers( + normalize_incoming_data(environ_or_headers), + op=op, + name=name, + source=source, + ) + + return transaction + + def capture_event(self, event, hint=None, client=None, scope=None, **scope_kwargs): + # type: (Event, Optional[Hint], Optional[sentry_sdk.Client], Optional[Scope], Any) -> Optional[str] + """ + Captures an event. + + Merges given scope data and calls :py:meth:`sentry_sdk.Client.capture_event`. + + :param event: A ready-made event that can be directly sent to Sentry. + + :param hint: Contains metadata about the event that can be read from `before_send`, such as the original exception object or a HTTP request object. + + :param client: The client to use for sending the event to Sentry. + + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :param scope_kwargs: Optional data to apply to event. + For supported `**scope_kwargs` see :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.Client.capture_event`). + """ + if client is None: + return None + + scope = _merge_scopes(self, scope, scope_kwargs) + + return client.capture_event(event=event, hint=hint, scope=scope) + + def capture_message( + self, message, level=None, client=None, scope=None, **scope_kwargs + ): + # type: (str, Optional[str], Optional[sentry_sdk.Client], Optional[Scope], Any) -> Optional[str] + """ + Captures a message. + + :param message: The string to send as the message. + + :param level: If no level is provided, the default level is `info`. + + :param client: The client to use for sending the event to Sentry. + + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :param scope_kwargs: Optional data to apply to event. + For supported `**scope_kwargs` see :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.Client.capture_event`). + """ + if client is None: + return None + + if level is None: + level = "info" + + event = { + "message": message, + "level": level, + } + + return self.capture_event(event, client=client, scope=scope, **scope_kwargs) + + def capture_exception(self, error=None, client=None, scope=None, **scope_kwargs): + # type: (Optional[Union[BaseException, ExcInfo]], Optional[sentry_sdk.Client], Optional[Scope], Any) -> Optional[str] + """Captures an exception. + + :param error: An exception to capture. If `None`, `sys.exc_info()` will be used. + + :param client: The client to use for sending the event to Sentry. + + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :param scope_kwargs: Optional data to apply to event. + For supported `**scope_kwargs` see :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.Client.capture_event`). + """ + if client is None: + return None + + if error is not None: + exc_info = exc_info_from_error(error) + else: + exc_info = sys.exc_info() + + event, hint = event_from_exception(exc_info, client_options=client.options) + + try: + return self.capture_event( + event, hint=hint, client=client, scope=scope, **scope_kwargs + ) + except Exception: + self._capture_internal_exception(sys.exc_info()) + + return None + + def _capture_internal_exception( + self, exc_info # type: Any + ): + # type: (...) -> Any + """ + Capture an exception that is likely caused by a bug in the SDK + itself. + + These exceptions do not end up in Sentry and are just logged instead. + """ + logger.error("Internal error in sentry_sdk", exc_info=exc_info) + + 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/sentry_sdk/utils.py b/sentry_sdk/utils.py index d547e363b6..25399cd908 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -4,6 +4,7 @@ import logging import math import os +import random import re import subprocess import sys @@ -1248,24 +1249,49 @@ def _make_threadlocal_contextvars(local): class ContextVar(object): # Super-limited impl of ContextVar - def __init__(self, name): - # type: (str) -> None + def __init__(self, name, default=None): + # type: (str, Any) -> None self._name = name + self._default = default self._local = local() + self._original_local = local() - def get(self, default): + def get(self, default=None): # type: (Any) -> Any - return getattr(self._local, "value", default) + return getattr(self._local, "value", default or self._default) def set(self, value): - # type: (Any) -> None + # type: (Any) -> Any + token = str(random.getrandbits(64)) + original_value = self.get() + setattr(self._original_local, token, original_value) self._local.value = value + return token + + def reset(self, token): + # type: (Any) -> None + self._local.value = getattr(self._original_local, token) + del self._original_local[token] return ContextVar +def _make_noop_copy_context(): + # type: () -> Callable[[], Any] + class NoOpContext: + def run(self, func, *args, **kwargs): + # type: (Callable[..., Any], *Any, **Any) -> Any + return func(*args, **kwargs) + + def copy_context(): + # type: () -> NoOpContext + return NoOpContext() + + return copy_context + + def _get_contextvars(): - # type: () -> Tuple[bool, type] + # type: () -> Tuple[bool, type, Callable[[], Any]] """ Figure out the "right" contextvars installation to use. Returns a `contextvars.ContextVar`-like class with a limited API. @@ -1281,17 +1307,17 @@ def _get_contextvars(): # `aiocontextvars` is absolutely required for functional # contextvars on Python 3.6. try: - from aiocontextvars import ContextVar + from aiocontextvars import ContextVar, copy_context - return True, ContextVar + return True, ContextVar, copy_context except ImportError: pass else: # On Python 3.7 contextvars are functional. try: - from contextvars import ContextVar + from contextvars import ContextVar, copy_context - return True, ContextVar + return True, ContextVar, copy_context except ImportError: pass @@ -1299,10 +1325,10 @@ def _get_contextvars(): from threading import local - return False, _make_threadlocal_contextvars(local) + return False, _make_threadlocal_contextvars(local), _make_noop_copy_context() -HAS_REAL_CONTEXTVARS, ContextVar = _get_contextvars() +HAS_REAL_CONTEXTVARS, ContextVar, copy_context = _get_contextvars() CONTEXTVARS_ERROR_MESSAGE = """ diff --git a/tests/test_client.py b/tests/test_client.py index 5a7a5cff16..fa55c1111a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -20,7 +20,7 @@ ) from sentry_sdk.integrations.executing import ExecutingIntegration from sentry_sdk.transport import Transport -from sentry_sdk._compat import reraise, text_type, PY2 +from sentry_sdk._compat import text_type, PY2 from sentry_sdk.utils import HAS_CHAINED_EXCEPTIONS from sentry_sdk.utils import logger from sentry_sdk.serializer import MAX_DATABAG_BREADTH @@ -358,24 +358,27 @@ def test_simple_transport(sentry_init): def test_ignore_errors(sentry_init, capture_events): - class MyDivisionError(ZeroDivisionError): - pass + with mock.patch( + "sentry_sdk.scope.Scope._capture_internal_exception" + ) as mock_capture_internal_exception: - def raise_it(exc_info): - reraise(*exc_info) + class MyDivisionError(ZeroDivisionError): + pass - sentry_init(ignore_errors=[ZeroDivisionError], transport=_TestTransport()) - Hub.current._capture_internal_exception = raise_it + sentry_init(ignore_errors=[ZeroDivisionError], transport=_TestTransport()) - def e(exc): - try: - raise exc - except Exception: - capture_exception() + def e(exc): + try: + raise exc + except Exception: + capture_exception() + + e(ZeroDivisionError()) + e(MyDivisionError()) + e(ValueError()) - e(ZeroDivisionError()) - e(MyDivisionError()) - pytest.raises(EventCapturedError, lambda: e(ValueError())) + assert mock_capture_internal_exception.call_count == 1 + assert mock_capture_internal_exception.call_args[0][0][0] == EventCapturedError def test_with_locals_deprecation_enabled(sentry_init): diff --git a/tests/utils/test_contextvars.py b/tests/utils/test_contextvars.py index a6d296bb1f..faf33e8580 100644 --- a/tests/utils/test_contextvars.py +++ b/tests/utils/test_contextvars.py @@ -12,7 +12,7 @@ def test_leaks(maybe_monkeypatched_threading): from sentry_sdk import utils - _, ContextVar = utils._get_contextvars() # noqa: N806 + _, ContextVar, _ = utils._get_contextvars() # noqa: N806 ts = []