From c611325c5cf04c132954e2b9c93a0e34db52ed43 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 29 Jul 2026 12:30:18 +0200 Subject: [PATCH 1/5] ref(profiler): Remove transaction profiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the transaction-bound profiler entirely, keeping only the continuous profiler. This deletes transaction_profiler.py and all code paths that created, started, stopped, and serialized transaction profiles — including the Profile class, Scheduler hierarchy, profiles_sample_rate/profiles_sampler options, scope.profile property, envelope "profile" item type, and update_active_thread_id calls in framework integrations. --- sentry_sdk/_types.py | 2 - sentry_sdk/client.py | 34 +- sentry_sdk/consts.py | 12 - sentry_sdk/envelope.py | 8 - sentry_sdk/integrations/django/asgi.py | 4 - sentry_sdk/integrations/django/views.py | 6 - sentry_sdk/integrations/fastapi.py | 4 - sentry_sdk/integrations/quart.py | 4 - sentry_sdk/integrations/starlette.py | 2 - sentry_sdk/profiler/__init__.py | 22 - sentry_sdk/profiler/transaction_profiler.py | 802 ----------------- sentry_sdk/scope.py | 23 - sentry_sdk/tracing.py | 13 - tests/conftest.py | 5 +- tests/integrations/django/asgi/test_asgi.py | 81 -- tests/integrations/fastapi/test_fastapi.py | 39 - tests/integrations/quart/test_quart.py | 78 -- .../integrations/starlette/test_starlette.py | 39 - tests/integrations/wsgi/test_wsgi.py | 27 - tests/profiler/test_transaction_profiler.py | 838 ------------------ tests/test_client.py | 39 - tests/test_envelope.py | 1 - 22 files changed, 9 insertions(+), 2074 deletions(-) delete mode 100644 sentry_sdk/profiler/transaction_profiler.py delete mode 100644 tests/profiler/test_transaction_profiler.py diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index e91fed097c..a49b33d77b 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -274,7 +274,6 @@ class DataCollection(TypedDict): "monitor_config": Mapping[str, object], "monitor_slug": Optional[str], "platform": Literal["python"], - "profile": object, # Should be sentry_sdk.profiler.Profile, but we can't import that here due to circular imports "release": Optional[str], "request": dict[str, object], "sdk": Mapping[str, object], @@ -418,7 +417,6 @@ class DataCollection(TypedDict): "attachment", "session", "internal", - "profile", "profile_chunk", "monitor", "span", diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index e8414d3f91..cb3de4b382 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -32,11 +32,6 @@ from sentry_sdk.integrations.dedupe import DedupeIntegration from sentry_sdk.monitor import Monitor from sentry_sdk.profiler.continuous_profiler import setup_continuous_profiler -from sentry_sdk.profiler.transaction_profiler import ( - Profile, - has_profiling_enabled, - setup_profiler, -) from sentry_sdk.scrubber import EventScrubber from sentry_sdk.serializer import serialize from sentry_sdk.sessions import SessionFlusher @@ -632,7 +627,6 @@ def _record_lost_event( self.options["send_default_pii"] = True self.options["error_sampler"] = sample_all self.options["traces_sampler"] = sample_all - self.options["profiles_sampler"] = sample_all # data_collection was resolved in _get_options() before this # spotlight override flipped send_default_pii on. Re-derive it so # data_collection agrees with should_send_default_pii() in @@ -708,20 +702,14 @@ def _record_lost_event( SDK_INFO["name"] = sdk_name logger.debug("Setting SDK name to '%s'", sdk_name) - if has_profiling_enabled(self.options): - try: - setup_profiler(self.options) - except Exception as e: - logger.debug("Can not set up profiler. (%s)", e) - else: - try: - setup_continuous_profiler( - self.options, - sdk_info=SDK_INFO, - capture_func=_capture_envelope, - ) - except Exception as e: - logger.debug("Can not set up continuous profiler. (%s)", e) + try: + setup_continuous_profiler( + self.options, + sdk_info=SDK_INFO, + capture_func=_capture_envelope, + ) + except Exception as e: + logger.debug("Can not set up continuous profiler. (%s)", e) finally: _client_init_debug.set(old_debug) @@ -733,7 +721,6 @@ def _record_lost_event( or self.log_batcher or self.metrics_batcher or self.span_batcher - or has_profiling_enabled(self.options) or isinstance(self.transport, HttpTransportCore) ): # If we have anything on that could spawn a background thread, we @@ -1121,8 +1108,6 @@ def capture_event( if not self._should_capture(event, hint, scope): return None - profile = event.pop("profile", None) - event_id = event.get("event_id") if event_id is None: event["event_id"] = event_id = uuid.uuid4().hex @@ -1163,9 +1148,6 @@ def capture_event( envelope = Envelope(headers=headers) - if is_transaction and isinstance(profile, Profile): - envelope.add_profile(profile.to_json(event_opt, self.options)) - if is_transaction and not span_recorder_has_gen_ai_span: envelope.add_transaction(event_opt) elif is_transaction: diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 3abaa4b6d9..36f7e4058e 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1301,8 +1301,6 @@ def __init__( traces_sample_rate: "Optional[float]" = None, trace_lifecycle: "Optional[Literal['static', 'stream']]" = None, traces_sampler: "Optional[TracesSampler]" = None, - profiles_sample_rate: "Optional[float]" = None, - profiles_sampler: "Optional[TracesSampler]" = None, profiler_mode: "Optional[ProfilerMode]" = None, profile_lifecycle: 'Literal["manual", "trace"]' = "manual", profile_session_sample_rate: "Optional[float]" = None, @@ -1705,16 +1703,6 @@ def __init__( Return a string for that repr value to be used or `None` to continue serializing how Sentry would have done it anyway. - :param profiles_sample_rate: A number between `0` and `1`, controlling the percentage chance a given sampled - transaction will be profiled. - - (`0` represents 0% while `1` represents 100%.) Applies equally to all transactions created in the app. - - This is relative to the tracing sample rate - e.g. `0.5` means 50% of sampled transactions will be - profiled. - - :param profiles_sampler: - :param profiler_mode: :param profile_lifecycle: diff --git a/sentry_sdk/envelope.py b/sentry_sdk/envelope.py index d2d4aae31a..c4e3a4d42d 100644 --- a/sentry_sdk/envelope.py +++ b/sentry_sdk/envelope.py @@ -59,12 +59,6 @@ def add_transaction( ) -> None: self.add_item(Item(payload=PayloadRef(json=transaction), type="transaction")) - def add_profile( - self, - profile: "Any", - ) -> None: - self.add_item(Item(payload=PayloadRef(json=profile), type="profile")) - def add_profile_chunk( self, profile_chunk: "Any", @@ -259,8 +253,6 @@ def data_category(self) -> "EventDataCategory": return "trace_metric" elif ty == "client_report": return "internal" - elif ty == "profile": - return "profile" elif ty == "profile_chunk": return "profile_chunk" elif ty == "check_in": diff --git a/sentry_sdk/integrations/django/asgi.py b/sentry_sdk/integrations/django/asgi.py index df287e6436..f3b1fd0898 100644 --- a/sentry_sdk/integrations/django/asgi.py +++ b/sentry_sdk/integrations/django/asgi.py @@ -188,10 +188,6 @@ async def sentry_wrapped_callback( if current_scope.transaction is not None: current_scope.transaction.update_active_thread() - sentry_scope = sentry_sdk.get_isolation_scope() - if sentry_scope.profile is not None: - sentry_scope.profile.update_active_thread_id() - integration = client.get_integration(DjangoIntegration) if not integration or not integration.middleware_spans: return await callback(request, *args, **kwargs) diff --git a/sentry_sdk/integrations/django/views.py b/sentry_sdk/integrations/django/views.py index b24e4df45b..eacd3b74fe 100644 --- a/sentry_sdk/integrations/django/views.py +++ b/sentry_sdk/integrations/django/views.py @@ -99,12 +99,6 @@ def sentry_wrapped_callback(request: "Any", *args: "Any", **kwargs: "Any") -> "A if current_scope.transaction is not None: current_scope.transaction.update_active_thread() - sentry_scope = sentry_sdk.get_isolation_scope() - # set the active thread id to the handler thread for sync views - # this isn't necessary for async views since that runs on main - if sentry_scope.profile is not None: - sentry_scope.profile.update_active_thread_id() - integration = client.get_integration(DjangoIntegration) if not integration or not integration.middleware_spans: return callback(request, *args, **kwargs) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 0b048e11ee..14ee66eecf 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -181,10 +181,6 @@ def _sentry_call(*args: "Any", **kwargs: "Any") -> "Any": elif current_scope.transaction is not None: current_scope.transaction.update_active_thread() - sentry_scope = sentry_sdk.get_isolation_scope() - if sentry_scope.profile is not None: - sentry_scope.profile.update_active_thread_id() - return old_call(*args, **kwargs) _sentry_call._sentry_is_patched = True # type: ignore[attr-defined] diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 8c281f5874..c3238abe55 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -133,10 +133,6 @@ def _sentry_func(*args: "Any", **kwargs: "Any") -> "Any": if current_scope.transaction is not None: current_scope.transaction.update_active_thread() - sentry_scope = sentry_sdk.get_isolation_scope() - if sentry_scope.profile is not None: - sentry_scope.profile.update_active_thread_id() - return old_func(*args, **kwargs) return old_decorator(_sentry_func) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 21cdfeecc5..ef01e48346 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -622,8 +622,6 @@ def _sentry_sync_func(*args: "Any", **kwargs: "Any") -> "Any": current_scope.transaction.update_active_thread() sentry_scope = sentry_sdk.get_isolation_scope() - if sentry_scope.profile is not None: - sentry_scope.profile.update_active_thread_id() request = args[0] diff --git a/sentry_sdk/profiler/__init__.py b/sentry_sdk/profiler/__init__.py index d562405295..99d8cd2d61 100644 --- a/sentry_sdk/profiler/__init__.py +++ b/sentry_sdk/profiler/__init__.py @@ -4,17 +4,6 @@ stop_profile_session, stop_profiler, ) -from sentry_sdk.profiler.transaction_profiler import ( - MAX_PROFILE_DURATION_NS, - PROFILE_MINIMUM_SAMPLES, - GeventScheduler, - Profile, - Scheduler, - ThreadScheduler, - has_profiling_enabled, - setup_profiler, - teardown_profiler, -) from sentry_sdk.profiler.utils import ( DEFAULT_SAMPLING_FREQUENCY, MAX_STACK_DEPTH, @@ -29,17 +18,6 @@ "start_profiler", "stop_profile_session", # TODO: Deprecate this in favor of `stop_profiler` "stop_profiler", - # DEPRECATED: The following was re-exported for backwards compatibility. It - # will be removed from sentry_sdk.profiler in a future release. - "MAX_PROFILE_DURATION_NS", - "PROFILE_MINIMUM_SAMPLES", - "Profile", - "Scheduler", - "ThreadScheduler", - "GeventScheduler", - "has_profiling_enabled", - "setup_profiler", - "teardown_profiler", "DEFAULT_SAMPLING_FREQUENCY", "MAX_STACK_DEPTH", "get_frame_name", diff --git a/sentry_sdk/profiler/transaction_profiler.py b/sentry_sdk/profiler/transaction_profiler.py deleted file mode 100644 index fe65774c0b..0000000000 --- a/sentry_sdk/profiler/transaction_profiler.py +++ /dev/null @@ -1,802 +0,0 @@ -""" -This file is originally based on code from https://github.com/nylas/nylas-perftools, -which is published under the following license: - -The MIT License (MIT) - -Copyright (c) 2014 Nylas - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - -import atexit -import os -import platform -import random -import sys -import threading -import time -import uuid -import warnings -from abc import ABC, abstractmethod -from collections import deque -from typing import TYPE_CHECKING - -import sentry_sdk -from sentry_sdk._lru_cache import LRUCache -from sentry_sdk.profiler.utils import ( - DEFAULT_SAMPLING_FREQUENCY, - extract_stack, -) -from sentry_sdk.utils import ( - capture_internal_exception, - capture_internal_exceptions, - get_current_thread_meta, - is_gevent, - is_valid_sample_rate, - logger, - nanosecond_time, - set_in_app_in_frames, -) - -if TYPE_CHECKING: - from typing import Any, Callable, Deque, Dict, List, Optional, Set, Type - - from typing_extensions import TypedDict - - from sentry_sdk._types import Event, ProfilerMode, SamplingContext - from sentry_sdk.profiler.utils import ( - ExtractedSample, - FrameId, - ProcessedFrame, - ProcessedStack, - ProcessedThreadMetadata, - StackId, - ThreadId, - ) - - ProcessedSample = TypedDict( - "ProcessedSample", - { - "elapsed_since_start_ns": str, - "thread_id": ThreadId, - "stack_id": int, - }, - ) - - ProcessedProfile = TypedDict( - "ProcessedProfile", - { - "frames": List[ProcessedFrame], - "stacks": List[ProcessedStack], - "samples": List[ProcessedSample], - "thread_metadata": Dict[ThreadId, ProcessedThreadMetadata], - }, - ) - - -try: - from gevent.monkey import get_original - from gevent.threadpool import ThreadPool as _ThreadPool - - ThreadPool: "Optional[Type[_ThreadPool]]" = _ThreadPool - thread_sleep = get_original("time", "sleep") -except ImportError: - thread_sleep = time.sleep - - ThreadPool = None - - -_scheduler: "Optional[Scheduler]" = None - - -# The minimum number of unique samples that must exist in a profile to be -# considered valid. -PROFILE_MINIMUM_SAMPLES = 2 - - -def has_profiling_enabled(options: "Dict[str, Any]") -> bool: - profiles_sampler = options["profiles_sampler"] - if profiles_sampler is not None: - return True - - profiles_sample_rate = options["profiles_sample_rate"] - if profiles_sample_rate is not None and profiles_sample_rate > 0: - return True - - profiles_sample_rate = options["_experiments"].get("profiles_sample_rate") - if profiles_sample_rate is not None: - logger.warning( - "_experiments['profiles_sample_rate'] is deprecated. " - "Please use the non-experimental profiles_sample_rate option " - "directly." - ) - if profiles_sample_rate > 0: - return True - - return False - - -def setup_profiler(options: "Dict[str, Any]") -> bool: - global _scheduler - - if _scheduler is not None: - logger.debug("[Profiling] Profiler is already setup") - return False - - frequency = DEFAULT_SAMPLING_FREQUENCY - - if is_gevent(): - # If gevent has patched the threading modules then we cannot rely on - # them to spawn a native thread for sampling. - # Instead we default to the GeventScheduler which is capable of - # spawning native threads within gevent. - default_profiler_mode = GeventScheduler.mode - else: - default_profiler_mode = ThreadScheduler.mode - - if options.get("profiler_mode") is not None: - profiler_mode = options["profiler_mode"] - else: - profiler_mode = options.get("_experiments", {}).get("profiler_mode") - if profiler_mode is not None: - logger.warning( - "_experiments['profiler_mode'] is deprecated. Please use the " - "non-experimental profiler_mode option directly." - ) - profiler_mode = profiler_mode or default_profiler_mode - - if ( - profiler_mode == ThreadScheduler.mode - # for legacy reasons, we'll keep supporting sleep mode for this scheduler - or profiler_mode == "sleep" - ): - _scheduler = ThreadScheduler(frequency=frequency) - elif profiler_mode == GeventScheduler.mode: - _scheduler = GeventScheduler(frequency=frequency) - else: - raise ValueError("Unknown profiler mode: {}".format(profiler_mode)) - - logger.debug( - "[Profiling] Setting up profiler in {mode} mode".format(mode=_scheduler.mode) - ) - _scheduler.setup() - - atexit.register(teardown_profiler) - - return True - - -def teardown_profiler() -> None: - global _scheduler - - if _scheduler is not None: - _scheduler.teardown() - - _scheduler = None - - -MAX_PROFILE_DURATION_NS = int(3e10) # 30 seconds - - -class Profile: - def __init__( - self, - sampled: "Optional[bool]", - start_ns: int, - hub: "Optional[sentry_sdk.Hub]" = None, - scheduler: "Optional[Scheduler]" = None, - ) -> None: - self.scheduler = _scheduler if scheduler is None else scheduler - - self.event_id: str = uuid.uuid4().hex - - self.sampled: "Optional[bool]" = sampled - - # Various framework integrations are capable of overwriting the active thread id. - # If it is set to `None` at the end of the profile, we fall back to the default. - self._default_active_thread_id: int = get_current_thread_meta()[0] or 0 - self.active_thread_id: "Optional[int]" = None - - try: - self.start_ns: int = start_ns - except AttributeError: - self.start_ns = 0 - - self.stop_ns: int = 0 - self.active: bool = False - - self.indexed_frames: "Dict[FrameId, int]" = {} - self.indexed_stacks: "Dict[StackId, int]" = {} - self.frames: "List[ProcessedFrame]" = [] - self.stacks: "List[ProcessedStack]" = [] - self.samples: "List[ProcessedSample]" = [] - - self.unique_samples = 0 - - # Backwards compatibility with the old hub property - self._hub: "Optional[sentry_sdk.Hub]" = None - if hub is not None: - self._hub = hub - warnings.warn( - "The `hub` parameter is deprecated. Please do not use it.", - DeprecationWarning, - stacklevel=2, - ) - - def update_active_thread_id(self) -> None: - self.active_thread_id = get_current_thread_meta()[0] - logger.debug( - "[Profiling] updating active thread id to {tid}".format( - tid=self.active_thread_id - ) - ) - - def _set_initial_sampling_decision( - self, sampling_context: "SamplingContext" - ) -> None: - """ - Sets the profile's sampling decision according to the following - precedence rules: - - 1. If the transaction to be profiled is not sampled, that decision - will be used, regardless of anything else. - - 2. Use `profiles_sample_rate` to decide. - """ - - # The corresponding transaction was not sampled, - # so don't generate a profile for it. - if not self.sampled: - logger.debug( - "[Profiling] Discarding profile because transaction is discarded." - ) - self.sampled = False - return - - # The profiler hasn't been properly initialized. - if self.scheduler is None: - logger.debug( - "[Profiling] Discarding profile because profiler was not started." - ) - self.sampled = False - return - - client = sentry_sdk.get_client() - if not client.is_active(): - self.sampled = False - return - - options = client.options - - if callable(options.get("profiles_sampler")): - sample_rate = options["profiles_sampler"](sampling_context) - elif options["profiles_sample_rate"] is not None: - sample_rate = options["profiles_sample_rate"] - else: - sample_rate = options["_experiments"].get("profiles_sample_rate") - - # The profiles_sample_rate option was not set, so profiling - # was never enabled. - if sample_rate is None: - logger.debug( - "[Profiling] Discarding profile because profiling was not enabled." - ) - self.sampled = False - return - - if not is_valid_sample_rate(sample_rate, source="Profiling"): - logger.warning( - "[Profiling] Discarding profile because of invalid sample rate." - ) - self.sampled = False - return - - # Now we roll the dice. random.random is inclusive of 0, but not of 1, - # so strict < is safe here. In case sample_rate is a boolean, cast it - # to a float (True becomes 1.0 and False becomes 0.0) - self.sampled = random.random() < float(sample_rate) - - if self.sampled: - logger.debug("[Profiling] Initializing profile") - else: - logger.debug( - "[Profiling] Discarding profile because it's not included in the random sample (sample rate = {sample_rate})".format( - sample_rate=float(sample_rate) - ) - ) - - def start(self) -> None: - if not self.sampled or self.active: - return - - assert self.scheduler, "No scheduler specified" - logger.debug("[Profiling] Starting profile") - self.active = True - if not self.start_ns: - self.start_ns = nanosecond_time() - self.scheduler.start_profiling(self) - - def stop(self) -> None: - if not self.sampled or not self.active: - return - - assert self.scheduler, "No scheduler specified" - logger.debug("[Profiling] Stopping profile") - self.active = False - self.stop_ns = nanosecond_time() - - def __enter__(self) -> "Profile": - scope = sentry_sdk.get_isolation_scope() - old_profile = scope.profile - scope.profile = self - - self._context_manager_state = (scope, old_profile) - - self.start() - - return self - - def __exit__( - self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" - ) -> None: - with capture_internal_exceptions(): - self.stop() - - scope, old_profile = self._context_manager_state - del self._context_manager_state - - scope.profile = old_profile - - def write(self, ts: int, sample: "ExtractedSample") -> None: - if not self.active: - return - - if ts < self.start_ns: - return - - offset = ts - self.start_ns - if offset > MAX_PROFILE_DURATION_NS: - self.stop() - return - - self.unique_samples += 1 - - elapsed_since_start_ns = str(offset) - - for tid, (stack_id, frame_ids, frames) in sample: - try: - # Check if the stack is indexed first, this lets us skip - # indexing frames if it's not necessary - if stack_id not in self.indexed_stacks: - for i, frame_id in enumerate(frame_ids): - if frame_id not in self.indexed_frames: - self.indexed_frames[frame_id] = len(self.indexed_frames) - self.frames.append(frames[i]) - - self.indexed_stacks[stack_id] = len(self.indexed_stacks) - self.stacks.append( - [self.indexed_frames[frame_id] for frame_id in frame_ids] - ) - - self.samples.append( - { - "elapsed_since_start_ns": elapsed_since_start_ns, - "thread_id": tid, - "stack_id": self.indexed_stacks[stack_id], - } - ) - except AttributeError: - # For some reason, the frame we get doesn't have certain attributes. - # When this happens, we abandon the current sample as it's bad. - capture_internal_exception(sys.exc_info()) - - def process(self) -> "ProcessedProfile": - # This collects the thread metadata at the end of a profile. Doing it - # this way means that any threads that terminate before the profile ends - # will not have any metadata associated with it. - thread_metadata: "Dict[str, ProcessedThreadMetadata]" = { - str(thread.ident): { - "name": str(thread.name), - } - for thread in threading.enumerate() - } - - return { - "frames": self.frames, - "stacks": self.stacks, - "samples": self.samples, - "thread_metadata": thread_metadata, - } - - def to_json( - self, event_opt: "Event", options: "Dict[str, Any]" - ) -> "Dict[str, Any]": - profile = self.process() - - set_in_app_in_frames( - profile["frames"], - options["in_app_exclude"], - options["in_app_include"], - options["project_root"], - ) - - return { - "environment": event_opt.get("environment"), - "event_id": self.event_id, - "platform": "python", - "profile": profile, - "release": event_opt.get("release", ""), - "timestamp": event_opt["start_timestamp"], - "version": "1", - "device": { - "architecture": platform.machine(), - }, - "os": { - "name": platform.system(), - "version": platform.release(), - }, - "runtime": { - "name": platform.python_implementation(), - "version": platform.python_version(), - }, - "transactions": [ - { - "id": event_opt["event_id"], - "name": event_opt["transaction"], - # we start the transaction before the profile and this is - # the transaction start time relative to the profile, so we - # hardcode it to 0 until we can start the profile before - "relative_start_ns": "0", - # use the duration of the profile instead of the transaction - # because we end the transaction after the profile - "relative_end_ns": str(self.stop_ns - self.start_ns), - "trace_id": event_opt["contexts"]["trace"]["trace_id"], - "active_thread_id": str( - self._default_active_thread_id - if self.active_thread_id is None - else self.active_thread_id - ), - } - ], - } - - def valid(self) -> bool: - client = sentry_sdk.get_client() - if not client.is_active(): - return False - - if not has_profiling_enabled(client.options): - return False - - if self.sampled is None or not self.sampled: - if client.transport: - client.transport.record_lost_event( - "sample_rate", data_category="profile" - ) - return False - - if self.unique_samples < PROFILE_MINIMUM_SAMPLES: - if client.transport: - client.transport.record_lost_event( - "insufficient_data", data_category="profile" - ) - logger.debug("[Profiling] Discarding profile because insufficient samples.") - return False - - return True - - @property - def hub(self) -> "Optional[sentry_sdk.Hub]": - warnings.warn( - "The `hub` attribute is deprecated. Please do not access it.", - DeprecationWarning, - stacklevel=2, - ) - return self._hub - - @hub.setter - def hub(self, value: "Optional[sentry_sdk.Hub]") -> None: - warnings.warn( - "The `hub` attribute is deprecated. Please do not set it.", - DeprecationWarning, - stacklevel=2, - ) - self._hub = value - - -class Scheduler(ABC): - mode: "ProfilerMode" = "unknown" - - def __init__(self, frequency: int) -> None: - self.interval = 1.0 / frequency - - self.sampler = self.make_sampler() - - # cap the number of new profiles at any time so it does not grow infinitely - self.new_profiles: "Deque[Profile]" = deque(maxlen=128) - self.active_profiles: "Set[Profile]" = set() - - def __enter__(self) -> "Scheduler": - self.setup() - return self - - def __exit__( - self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" - ) -> None: - self.teardown() - - @abstractmethod - def setup(self) -> None: - pass - - @abstractmethod - def teardown(self) -> None: - pass - - def ensure_running(self) -> None: - """ - Ensure the scheduler is running. By default, this method is a no-op. - The method should be overridden by any implementation for which it is - relevant. - """ - return None - - def start_profiling(self, profile: "Profile") -> None: - self.ensure_running() - self.new_profiles.append(profile) - - def make_sampler(self) -> "Callable[..., None]": - cwd = os.getcwd() - - cache = LRUCache(max_size=256) - - def _sample_stack(*args: "Any", **kwargs: "Any") -> None: - """ - Take a sample of the stack on all the threads in the process. - This should be called at a regular interval to collect samples. - """ - # no profiles taking place, so we can stop early - if not self.new_profiles and not self.active_profiles: - # make sure to clear the cache if we're not profiling so we dont - # keep a reference to the last stack of frames around - return - - # This is the number of profiles we want to pop off. - # It's possible another thread adds a new profile to - # the list and we spend longer than we want inside - # the loop below. - # - # Also make sure to set this value before extracting - # frames so we do not write to any new profiles that - # were started after this point. - new_profiles = len(self.new_profiles) - - now = nanosecond_time() - - try: - sample = [ - (str(tid), extract_stack(frame, cache, cwd)) - for tid, frame in sys._current_frames().items() - ] - except AttributeError: - # For some reason, the frame we get doesn't have certain attributes. - # When this happens, we abandon the current sample as it's bad. - capture_internal_exception(sys.exc_info()) - return - - # Move the new profiles into the active_profiles set. - # - # We cannot directly add the to active_profiles set - # in `start_profiling` because it is called from other - # threads which can cause a RuntimeError when it the - # set sizes changes during iteration without a lock. - # - # We also want to avoid using a lock here so threads - # that are starting profiles are not blocked until it - # can acquire the lock. - for _ in range(new_profiles): - self.active_profiles.add(self.new_profiles.popleft()) - - inactive_profiles = [] - - for profile in self.active_profiles: - if profile.active: - profile.write(now, sample) - else: - # If a profile is marked inactive, we buffer it - # to `inactive_profiles` so it can be removed. - # We cannot remove it here as it would result - # in a RuntimeError. - inactive_profiles.append(profile) - - for profile in inactive_profiles: - self.active_profiles.remove(profile) - - return _sample_stack - - -class ThreadScheduler(Scheduler): - """ - This scheduler is based on running a daemon thread that will call - the sampler at a regular interval. - """ - - mode: "ProfilerMode" = "thread" - name = "sentry.profiler.ThreadScheduler" - - def __init__(self, frequency: int) -> None: - super().__init__(frequency=frequency) - - # used to signal to the thread that it should stop - self.running = False - self.thread: "Optional[threading.Thread]" = None - self.pid: "Optional[int]" = None - self.lock = threading.Lock() - - def setup(self) -> None: - pass - - def teardown(self) -> None: - if self.running: - self.running = False - if self.thread is not None: - self.thread.join() - - def ensure_running(self) -> None: - """ - Check that the profiler has an active thread to run in, and start one if - that's not the case. - - Note that this might fail (e.g. in Python 3.12 it's not possible to - spawn new threads at interpreter shutdown). In that case self.running - will be False after running this function. - """ - pid = os.getpid() - - # is running on the right process - if self.running and self.pid == pid: - return - - with self.lock: - # another thread may have tried to acquire the lock - # at the same time so it may start another thread - # make sure to check again before proceeding - if self.running and self.pid == pid: - return - - self.pid = pid - self.running = True - - # make sure the thread is a daemon here otherwise this - # can keep the application running after other threads - # have exited - self.thread = threading.Thread(name=self.name, target=self.run, daemon=True) - try: - self.thread.start() - except RuntimeError: - # Unfortunately at this point the interpreter is in a state that no - # longer allows us to spawn a thread and we have to bail. - self.running = False - self.thread = None - return - - def run(self) -> None: - last = time.perf_counter() - - while self.running: - self.sampler() - - # some time may have elapsed since the last time - # we sampled, so we need to account for that and - # not sleep for too long - elapsed = time.perf_counter() - last - if elapsed < self.interval: - thread_sleep(self.interval - elapsed) - - # after sleeping, make sure to take the current - # timestamp so we can use it next iteration - last = time.perf_counter() - - -class GeventScheduler(Scheduler): - """ - This scheduler is based on the thread scheduler but adapted to work with - gevent. When using gevent, it may monkey patch the threading modules - (`threading` and `_thread`). This results in the use of greenlets instead - of native threads. - - This is an issue because the sampler CANNOT run in a greenlet because - 1. Other greenlets doing sync work will prevent the sampler from running - 2. The greenlet runs in the same thread as other greenlets so when taking - a sample, other greenlets will have been evicted from the thread. This - results in a sample containing only the sampler's code. - """ - - mode: "ProfilerMode" = "gevent" - name = "sentry.profiler.GeventScheduler" - - def __init__(self, frequency: int) -> None: - if ThreadPool is None: - raise ValueError("Profiler mode: {} is not available".format(self.mode)) - - super().__init__(frequency=frequency) - - # used to signal to the thread that it should stop - self.running = False - self.thread: "Optional[_ThreadPool]" = None - self.pid: "Optional[int]" = None - - # This intentionally uses the gevent patched threading.Lock. - # The lock will be required when first trying to start profiles - # as we need to spawn the profiler thread from the greenlets. - self.lock = threading.Lock() - - def setup(self) -> None: - pass - - def teardown(self) -> None: - if self.running: - self.running = False - if self.thread is not None: - self.thread.join() - - def ensure_running(self) -> None: - pid = os.getpid() - - # is running on the right process - if self.running and self.pid == pid: - return - - with self.lock: - # another thread may have tried to acquire the lock - # at the same time so it may start another thread - # make sure to check again before proceeding - if self.running and self.pid == pid: - return - - self.pid = pid - self.running = True - - self.thread = ThreadPool(1) # type: ignore[misc] - try: - self.thread.spawn(self.run) - except RuntimeError: - # Unfortunately at this point the interpreter is in a state that no - # longer allows us to spawn a thread and we have to bail. - self.running = False - self.thread = None - return - - def run(self) -> None: - last = time.perf_counter() - - while self.running: - self.sampler() - - # some time may have elapsed since the last time - # we sampled, so we need to account for that and - # not sleep for too long - elapsed = time.perf_counter() - last - if elapsed < self.interval: - thread_sleep(self.interval - elapsed) - - # after sleeping, make sure to take the current - # timestamp so we can use it next iteration - last = time.perf_counter() diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 09e8876d6d..22a642994a 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -26,7 +26,6 @@ try_autostart_continuous_profiler, try_profile_lifecycle_trace_start, ) -from sentry_sdk.profiler.transaction_profiler import Profile from sentry_sdk.session import Session from sentry_sdk.traces import ( _DEFAULT_PARENT_SPAN, @@ -235,7 +234,6 @@ class Scope: "_session", "_attachments", "_force_auto_session_tracking", - "_profile", "_propagation_context", "client", "_type", @@ -302,8 +300,6 @@ def __copy__(self) -> "Scope": rv._force_auto_session_tracking = self._force_auto_session_tracking rv._attachments = self._attachments.copy() - rv._profile = self._profile - rv._last_event_id = self._last_event_id rv._flags = deepcopy(self._flags) @@ -758,8 +754,6 @@ def clear(self) -> None: self._session: "Optional[Session]" = None self._force_auto_session_tracking: "Optional[bool]" = None - self._profile: "Optional[Profile]" = None - self._propagation_context = None # self._last_event_id is only applicable to isolation scopes @@ -932,14 +926,6 @@ def streamed_span(self, span: "Optional[StreamedSpan]") -> None: span._attributes["sentry.segment.name.source"] ) - @property - def profile(self) -> "Optional[Profile]": - return self._profile - - @profile.setter - def profile(self, profile: "Optional[Profile]") -> None: - self._profile = profile - def set_tag(self, key: str, value: "Any") -> None: """ Sets a tag for a key to a specific value. @@ -1193,13 +1179,6 @@ def start_transaction( ) if transaction.sampled: - profile = Profile( - transaction.sampled, transaction._start_timestamp_monotonic_ns - ) - profile._set_initial_sampling_decision(sampling_context=sampling_context) - - transaction._profile = profile - transaction._continuous_profile = try_profile_lifecycle_trace_start() # Typically, the profiler is set when the transaction is created. But when @@ -1964,8 +1943,6 @@ def update_from_scope(self, scope: "Scope") -> None: self._span = scope._span if scope._attachments: self._attachments.extend(scope._attachments) - if scope._profile: - self._profile = scope._profile if scope._propagation_context: self._propagation_context = scope._propagation_context if scope._session: diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 5c088b936b..6b463b68f5 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -43,7 +43,6 @@ SamplingContext, ) from sentry_sdk.profiler.continuous_profiler import ContinuousProfile - from sentry_sdk.profiler.transaction_profiler import Profile class SpanKwargs(TypedDict, total=False): trace_id: str @@ -817,7 +816,6 @@ class Transaction(Span): "sample_rate", "_measurements", "_contexts", - "_profile", "_continuous_profile", "_baggage", "_sample_rand", @@ -839,7 +837,6 @@ def __init__( # type: ignore[misc] self.parent_sampled = parent_sampled self._measurements: "Dict[str, MeasurementValue]" = {} self._contexts: "Dict[str, Any]" = {} - self._profile: "Optional[Profile]" = None self._continuous_profile: "Optional[ContinuousProfile]" = None self._baggage = baggage @@ -888,17 +885,11 @@ def __enter__(self) -> "Transaction": super().__enter__() - if self._profile is not None: - self._profile.__enter__() - return self def __exit__( self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" ) -> None: - if self._profile is not None: - self._profile.__exit__(ty, value, tb) - if self._continuous_profile is not None: self._continuous_profile.stop() @@ -1105,10 +1096,6 @@ def finish( if has_gen_ai_span: event["_has_gen_ai_span"] = True - if self._profile is not None and self._profile.valid(): - event["profile"] = self._profile - self._profile = None - event["measurements"] = self._measurements return scope.capture_event(event) diff --git a/tests/conftest.py b/tests/conftest.py index 6b406d6a06..8522511900 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -61,7 +61,6 @@ _installed_integrations, _processed_integrations, ) -from sentry_sdk.profiler import teardown_profiler from sentry_sdk.profiler.continuous_profiler import teardown_continuous_profiler from sentry_sdk.transport import Transport from sentry_sdk.utils import package_version, reraise @@ -776,13 +775,11 @@ def __ne__(self, test_obj): @pytest.fixture def teardown_profiling(): # Make sure that a previous test didn't leave the profiler running - teardown_profiler() teardown_continuous_profiler() yield - # Make sure that to shut down the profiler after the test - teardown_profiler() + # Make sure to shut down the profiler after the test teardown_continuous_profiler() diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py index 8c58a5c6a6..8ad22a1057 100644 --- a/tests/integrations/django/asgi/test_asgi.py +++ b/tests/integrations/django/asgi/test_asgi.py @@ -1,10 +1,8 @@ import asyncio import base64 import inspect -import json import os import sys -from unittest import mock import django import pytest @@ -197,85 +195,6 @@ async def test_async_views( } -@pytest.mark.parametrize("application", APPS) -@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) -@pytest.mark.parametrize("middleware_spans", [False, True]) -@pytest.mark.asyncio -@pytest.mark.skipif( - django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" -) -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_active_thread_id( - sentry_init, - capture_envelopes, - capture_items, - teardown_profiling, - endpoint, - application, - middleware_spans, - span_streaming, -): - sentry_init( - integrations=[DjangoIntegration(middleware_spans=middleware_spans)], - traces_sample_rate=1.0, - profiles_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", - ) - - comm = HttpCommunicator(application, "GET", endpoint) - - if span_streaming: - with mock.patch( - "sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0 - ): - items = capture_items("span") - response = await comm.get_response() - await comm.wait() - - assert response["status"] == 200, response["body"] - - data = json.loads(response["body"]) - - sentry_sdk.flush() - spans = [item.payload for item in items] - - for span in spans: - if span["is_segment"] is False: - continue - assert str(data["active"]) == span["attributes"]["thread.id"] - else: - with mock.patch( - "sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0 - ): - envelopes = capture_envelopes() - response = await comm.get_response() - await comm.wait() - - assert response["status"] == 200, response["body"] - - assert len(envelopes) == 1 - - profiles = [item for item in envelopes[0].items if item.type == "profile"] - assert len(profiles) == 1 - - data = json.loads(response["body"]) - - for item in profiles: - transactions = item.payload.json["transactions"] - assert len(transactions) == 1 - assert str(data["active"]) == transactions[0]["active_thread_id"] - - transactions = [ - item for item in envelopes[0].items if item.type == "transaction" - ] - assert len(transactions) == 1 - - for item in transactions: - transaction = item.payload.json - trace_context = transaction["contexts"]["trace"] - assert str(data["active"]) == trace_context["data"]["thread.id"] - - @pytest.mark.asyncio @pytest.mark.skipif( django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 41424bc27f..2e7383e758 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -4,7 +4,6 @@ import os import threading import warnings -from unittest import mock import fastapi import pytest @@ -464,44 +463,6 @@ def test_legacy_setup( assert event["transaction"] == "/message/{message_id}" -@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) -@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0) -def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, endpoint): - sentry_init( - traces_sample_rate=1.0, - profiles_sample_rate=1.0, - ) - app = fastapi_app_factory() - asgi_app = SentryAsgiMiddleware(app) - - envelopes = capture_envelopes() - - client = TestClient(asgi_app) - response = client.get(endpoint) - assert response.status_code == 200 - - data = json.loads(response.content) - - envelopes = [envelope for envelope in envelopes] - assert len(envelopes) == 1 - - profiles = [item for item in envelopes[0].items if item.type == "profile"] - assert len(profiles) == 1 - - for item in profiles: - transactions = item.payload.json["transactions"] - assert len(transactions) == 1 - assert str(data["active"]) == transactions[0]["active_thread_id"] - - transactions = [item for item in envelopes[0].items if item.type == "transaction"] - assert len(transactions) == 1 - - for item in transactions: - transaction = item.payload.json - trace_context = transaction["contexts"]["trace"] - assert str(data["active"]) == trace_context["data"]["thread.id"] - - @pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) def test_active_thread_id_span_streaming(sentry_init, capture_items, endpoint): sentry_init( diff --git a/tests/integrations/quart/test_quart.py b/tests/integrations/quart/test_quart.py index 286d6aeaff..59c947615f 100644 --- a/tests/integrations/quart/test_quart.py +++ b/tests/integrations/quart/test_quart.py @@ -1,8 +1,6 @@ import importlib -import json import sys import threading -from unittest import mock import pytest @@ -589,82 +587,6 @@ async def dispatch_request(self): assert event["transaction"] == "hello_class" -@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) -@pytest.mark.asyncio -async def test_active_thread_id( - sentry_init, capture_envelopes, teardown_profiling, endpoint -): - with mock.patch( - "sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0 - ): - sentry_init( - traces_sample_rate=1.0, - profiles_sample_rate=1.0, - ) - app = quart_app_factory() - - envelopes = capture_envelopes() - - async with app.test_client() as client: - response = await client.get(endpoint) - assert response.status_code == 200 - - data = json.loads(await response.get_data(as_text=True)) - - envelopes = [envelope for envelope in envelopes] - assert len(envelopes) == 1 - - profiles = [item for item in envelopes[0].items if item.type == "profile"] - assert len(profiles) == 1, envelopes[0].items - - for item in profiles: - transactions = item.payload.json["transactions"] - assert len(transactions) == 1 - assert str(data["active"]) == transactions[0]["active_thread_id"] - - transactions = [ - item for item in envelopes[0].items if item.type == "transaction" - ] - assert len(transactions) == 1 - - for item in transactions: - transaction = item.payload.json - trace_context = transaction["contexts"]["trace"] - assert str(data["active"]) == trace_context["data"]["thread.id"] - - -@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) -@pytest.mark.asyncio -async def test_active_thread_id_span_streaming( - sentry_init, capture_items, teardown_profiling, endpoint -): - with mock.patch( - "sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0 - ): - sentry_init( - traces_sample_rate=1.0, - profiles_sample_rate=1.0, - trace_lifecycle="stream", - ) - app = quart_app_factory() - - items = capture_items("span") - - async with app.test_client() as client: - response = await client.get(endpoint) - assert response.status_code == 200 - - data = json.loads(await response.get_data(as_text=True)) - - sentry_sdk.flush() - - spans = [item.payload for item in items] - assert len(spans) == 1 - - segment = spans[0] - assert str(data["active"]) == segment["attributes"]["thread.id"] - - @pytest.mark.asyncio async def test_span_origin(sentry_init, capture_events): sentry_init( diff --git a/tests/integrations/starlette/test_starlette.py b/tests/integrations/starlette/test_starlette.py index bf0d7b0993..2ef4a7ab57 100644 --- a/tests/integrations/starlette/test_starlette.py +++ b/tests/integrations/starlette/test_starlette.py @@ -7,7 +7,6 @@ import re import threading import warnings -from unittest import mock import pytest import starlette @@ -1453,44 +1452,6 @@ def test_legacy_setup( assert event["transaction"] == "/message/{message_id}" -@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) -@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0) -def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, endpoint): - sentry_init( - traces_sample_rate=1.0, - profiles_sample_rate=1.0, - ) - app = starlette_app_factory() - asgi_app = SentryAsgiMiddleware(app) - - envelopes = capture_envelopes() - - client = TestClient(asgi_app) - response = client.get(endpoint) - assert response.status_code == 200 - - data = json.loads(response.content) - - envelopes = [envelope for envelope in envelopes] - assert len(envelopes) == 1 - - profiles = [item for item in envelopes[0].items if item.type == "profile"] - assert len(profiles) == 1 - - for item in profiles: - transactions = item.payload.json["transactions"] - assert len(transactions) == 1 - assert str(data["active"]) == transactions[0]["active_thread_id"] - - transactions = [item for item in envelopes[0].items if item.type == "transaction"] - assert len(transactions) == 1 - - for item in transactions: - transaction = item.payload.json - trace_context = transaction["contexts"]["trace"] - assert str(data["active"]) == trace_context["data"]["thread.id"] - - @pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) def test_active_thread_id_span_streaming(sentry_init, capture_items, endpoint): sentry_init( diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py index eb73be1499..ca7761a7b4 100644 --- a/tests/integrations/wsgi/test_wsgi.py +++ b/tests/integrations/wsgi/test_wsgi.py @@ -614,33 +614,6 @@ def sample_app(environ, start_response): assert sum(agg.get("crashed", 0) for agg in session_aggregates) == 1 -@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0) -def test_profile_sent( - sentry_init, - capture_envelopes, - teardown_profiling, -): - def test_app(environ, start_response): - start_response("200 OK", []) - return ["Go get the ball! Good dog!"] - - sentry_init( - traces_sample_rate=1.0, - _experiments={"profiles_sample_rate": 1.0}, - ) - app = SentryWsgiMiddleware(test_app) - envelopes = capture_envelopes() - - client = Client(app) - client.get("/") - - envelopes = [envelope for envelope in envelopes] - assert len(envelopes) == 1 - - profiles = [item for item in envelopes[0].items if item.type == "profile"] - assert len(profiles) == 1 - - @pytest.mark.parametrize("span_streaming", [True, False]) def test_span_origin_manual(sentry_init, capture_events, capture_items, span_streaming): def dogpark(environ, start_response): diff --git a/tests/profiler/test_transaction_profiler.py b/tests/profiler/test_transaction_profiler.py deleted file mode 100644 index 749e91add0..0000000000 --- a/tests/profiler/test_transaction_profiler.py +++ /dev/null @@ -1,838 +0,0 @@ -import inspect -import os -import sys -import threading -import time -import warnings -from collections import defaultdict -from unittest import mock - -import pytest - -import sentry_sdk -from sentry_sdk import start_transaction -from sentry_sdk._lru_cache import LRUCache -from sentry_sdk.profiler.transaction_profiler import ( - GeventScheduler, - Profile, - Scheduler, - ThreadScheduler, - setup_profiler, -) -from sentry_sdk.profiler.utils import ( - extract_frame, - extract_stack, - frame_id, - get_frame_name, -) - -try: - import gevent -except ImportError: - gevent = None - - -requires_gevent = pytest.mark.skipif(gevent is None, reason="gevent not enabled") - - -def process_test_sample(sample): - # insert a mock hashable for the stack - return [(tid, (stack, stack)) for tid, stack in sample] - - -def non_experimental_options(mode=None, sample_rate=None): - return {"profiler_mode": mode, "profiles_sample_rate": sample_rate} - - -def experimental_options(mode=None, sample_rate=None): - return { - "_experiments": {"profiler_mode": mode, "profiles_sample_rate": sample_rate} - } - - -@pytest.mark.parametrize( - "mode", - [pytest.param("foo")], -) -@pytest.mark.parametrize( - "make_options", - [ - pytest.param(experimental_options, id="experiment"), - pytest.param(non_experimental_options, id="non experimental"), - ], -) -def test_profiler_invalid_mode(mode, make_options, teardown_profiling): - with pytest.raises(ValueError): - setup_profiler(make_options(mode)) - - -@pytest.mark.parametrize( - "mode", - [ - pytest.param("thread"), - pytest.param("sleep"), - pytest.param("gevent", marks=requires_gevent), - ], -) -@pytest.mark.parametrize( - "make_options", - [ - pytest.param(experimental_options, id="experiment"), - pytest.param(non_experimental_options, id="non experimental"), - ], -) -def test_profiler_valid_mode(mode, make_options, teardown_profiling): - # should not raise any exceptions - setup_profiler(make_options(mode)) - - -@pytest.mark.parametrize( - "make_options", - [ - pytest.param(experimental_options, id="experiment"), - pytest.param(non_experimental_options, id="non experimental"), - ], -) -def test_profiler_setup_twice(make_options, teardown_profiling): - # setting up the first time should return True to indicate success - assert setup_profiler(make_options()) - # setting up the second time should return False to indicate no-op - assert not setup_profiler(make_options()) - - -@pytest.mark.parametrize( - "mode", - [ - pytest.param("thread"), - pytest.param("gevent", marks=requires_gevent), - ], -) -@pytest.mark.parametrize( - ("profiles_sample_rate", "profile_count"), - [ - pytest.param(1.00, 1, id="profiler sampled at 1.00"), - pytest.param(0.75, 1, id="profiler sampled at 0.75"), - pytest.param(0.25, 0, id="profiler sampled at 0.25"), - pytest.param(0.00, 0, id="profiler sampled at 0.00"), - pytest.param(None, 0, id="profiler not enabled"), - ], -) -@pytest.mark.parametrize( - "make_options", - [ - pytest.param(experimental_options, id="experiment"), - pytest.param(non_experimental_options, id="non experimental"), - ], -) -@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0) -def test_profiles_sample_rate( - sentry_init, - capture_envelopes, - capture_record_lost_event_calls, - teardown_profiling, - profiles_sample_rate, - profile_count, - make_options, - mode, -): - options = make_options(mode=mode, sample_rate=profiles_sample_rate) - sentry_init( - traces_sample_rate=1.0, - profiler_mode=options.get("profiler_mode"), - profiles_sample_rate=options.get("profiles_sample_rate"), - _experiments=options.get("_experiments", {}), - ) - - envelopes = capture_envelopes() - record_lost_event_calls = capture_record_lost_event_calls() - - with mock.patch( - "sentry_sdk.profiler.transaction_profiler.random.random", return_value=0.5 - ): - with start_transaction(name="profiling"): - pass - - items = defaultdict(list) - for envelope in envelopes: - for item in envelope.items: - items[item.type].append(item) - - assert len(items["transaction"]) == 1 - assert len(items["profile"]) == profile_count - if profiles_sample_rate is None or profiles_sample_rate == 0: - assert record_lost_event_calls == [] - elif profile_count: - assert record_lost_event_calls == [] - else: - assert record_lost_event_calls == [("sample_rate", "profile", None, 1)] - - -@pytest.mark.parametrize( - "mode", - [ - pytest.param("thread"), - pytest.param("gevent", marks=requires_gevent), - ], -) -@pytest.mark.parametrize( - ("profiles_sampler", "profile_count"), - [ - pytest.param(lambda _: 1.00, 1, id="profiler sampled at 1.00"), - pytest.param(lambda _: 0.75, 1, id="profiler sampled at 0.75"), - pytest.param(lambda _: 0.25, 0, id="profiler sampled at 0.25"), - pytest.param(lambda _: 0.00, 0, id="profiler sampled at 0.00"), - pytest.param(lambda _: None, 0, id="profiler not enabled"), - pytest.param( - lambda ctx: 1 if ctx["transaction_context"]["name"] == "profiling" else 0, - 1, - id="profiler sampled for transaction name", - ), - pytest.param( - lambda ctx: 0 if ctx["transaction_context"]["name"] == "profiling" else 1, - 0, - id="profiler not sampled for transaction name", - ), - pytest.param( - lambda _: "1", 0, id="profiler not sampled because string sample rate" - ), - pytest.param(lambda _: True, 1, id="profiler sampled at True"), - pytest.param(lambda _: False, 0, id="profiler sampled at False"), - ], -) -@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0) -def test_profiles_sampler( - sentry_init, - capture_envelopes, - capture_record_lost_event_calls, - teardown_profiling, - profiles_sampler, - profile_count, - mode, -): - sentry_init( - traces_sample_rate=1.0, - profiles_sampler=profiles_sampler, - ) - - envelopes = capture_envelopes() - record_lost_event_calls = capture_record_lost_event_calls() - - with mock.patch( - "sentry_sdk.profiler.transaction_profiler.random.random", return_value=0.5 - ): - with start_transaction(name="profiling"): - pass - - items = defaultdict(list) - for envelope in envelopes: - for item in envelope.items: - items[item.type].append(item) - - assert len(items["transaction"]) == 1 - assert len(items["profile"]) == profile_count - if profile_count: - assert record_lost_event_calls == [] - else: - assert record_lost_event_calls == [("sample_rate", "profile", None, 1)] - - -def test_minimum_unique_samples_required( - sentry_init, - capture_envelopes, - capture_record_lost_event_calls, - teardown_profiling, -): - sentry_init( - traces_sample_rate=1.0, - _experiments={"profiles_sample_rate": 1.0}, - ) - - envelopes = capture_envelopes() - record_lost_event_calls = capture_record_lost_event_calls() - - with start_transaction(name="profiling"): - pass - - items = defaultdict(list) - for envelope in envelopes: - for item in envelope.items: - items[item.type].append(item) - - assert len(items["transaction"]) == 1 - # because we dont leave any time for the profiler to - # take any samples, it should be not be sent - assert len(items["profile"]) == 0 - assert record_lost_event_calls == [("insufficient_data", "profile", None, 1)] - - -@pytest.mark.forked -def test_profile_captured( - sentry_init, - capture_envelopes, - teardown_profiling, -): - sentry_init( - traces_sample_rate=1.0, - _experiments={"profiles_sample_rate": 1.0}, - ) - - envelopes = capture_envelopes() - - with start_transaction(name="profiling"): - time.sleep(0.05) - - items = defaultdict(list) - for envelope in envelopes: - for item in envelope.items: - items[item.type].append(item) - - assert len(items["transaction"]) == 1 - assert len(items["profile"]) == 1 - - -def get_frame(depth=1): - """ - This function is not exactly true to its name. Depending on - how it is called, the true depth of the stack can be deeper - than the argument implies. - """ - if depth <= 0: - raise ValueError("only positive integers allowed") - if depth > 1: - return get_frame(depth=depth - 1) - return inspect.currentframe() - - -class GetFrameBase: - def inherited_instance_method(self): - return inspect.currentframe() - - def inherited_instance_method_wrapped(self): - def wrapped(): - return inspect.currentframe() - - return wrapped - - @classmethod - def inherited_class_method(cls): - return inspect.currentframe() - - @classmethod - def inherited_class_method_wrapped(cls): - def wrapped(): - return inspect.currentframe() - - return wrapped - - @staticmethod - def inherited_static_method(): - return inspect.currentframe() - - -class GetFrame(GetFrameBase): - def instance_method(self): - return inspect.currentframe() - - def instance_method_wrapped(self): - def wrapped(): - return inspect.currentframe() - - return wrapped - - @classmethod - def class_method(cls): - return inspect.currentframe() - - @classmethod - def class_method_wrapped(cls): - def wrapped(): - return inspect.currentframe() - - return wrapped - - @staticmethod - def static_method(): - return inspect.currentframe() - - -@pytest.mark.parametrize( - ("frame", "frame_name"), - [ - pytest.param( - get_frame(), - "get_frame", - id="function", - ), - pytest.param( - (lambda: inspect.currentframe())(), - "", - id="lambda", - ), - pytest.param( - GetFrame().instance_method(), - "GetFrame.instance_method", - id="instance_method", - ), - pytest.param( - GetFrame().instance_method_wrapped()(), - ( - "wrapped" - if sys.version_info < (3, 11) - else "GetFrame.instance_method_wrapped..wrapped" - ), - id="instance_method_wrapped", - ), - pytest.param( - GetFrame().class_method(), - "GetFrame.class_method", - id="class_method", - ), - pytest.param( - GetFrame().class_method_wrapped()(), - ( - "wrapped" - if sys.version_info < (3, 11) - else "GetFrame.class_method_wrapped..wrapped" - ), - id="class_method_wrapped", - ), - pytest.param( - GetFrame().static_method(), - "static_method" if sys.version_info < (3, 11) else "GetFrame.static_method", - id="static_method", - ), - pytest.param( - GetFrame().inherited_instance_method(), - "GetFrameBase.inherited_instance_method", - id="inherited_instance_method", - ), - pytest.param( - GetFrame().inherited_instance_method_wrapped()(), - ( - "wrapped" - if sys.version_info < (3, 11) - else "GetFrameBase.inherited_instance_method_wrapped..wrapped" - ), - id="instance_method_wrapped", - ), - pytest.param( - GetFrame().inherited_class_method(), - "GetFrameBase.inherited_class_method", - id="inherited_class_method", - ), - pytest.param( - GetFrame().inherited_class_method_wrapped()(), - ( - "wrapped" - if sys.version_info < (3, 11) - else "GetFrameBase.inherited_class_method_wrapped..wrapped" - ), - id="inherited_class_method_wrapped", - ), - pytest.param( - GetFrame().inherited_static_method(), - ( - "inherited_static_method" - if sys.version_info < (3, 11) - else "GetFrameBase.inherited_static_method" - ), - id="inherited_static_method", - ), - ], -) -def test_get_frame_name(frame, frame_name): - assert get_frame_name(frame) == frame_name - - -@pytest.mark.parametrize( - ("get_frame", "function"), - [ - pytest.param(lambda: get_frame(depth=1), "get_frame", id="simple"), - ], -) -def test_extract_frame(get_frame, function): - cwd = os.getcwd() - frame = get_frame() - extracted_frame = extract_frame(frame_id(frame), frame, cwd) - - # the abs_path should be equal toe the normalized path of the co_filename - assert extracted_frame["abs_path"] == os.path.normpath(frame.f_code.co_filename) - - # the module should be pull from this test module - assert extracted_frame["module"] == __name__ - - # the filename should be the file starting after the cwd - assert extracted_frame["filename"] == __file__[len(cwd) + 1 :] - - assert extracted_frame["function"] == function - - # the lineno will shift over time as this file is modified so just check - # that it is an int - assert isinstance(extracted_frame["lineno"], int) - - -@pytest.mark.parametrize( - ("depth", "max_stack_depth", "actual_depth"), - [ - pytest.param(1, 128, 1, id="less than"), - pytest.param(256, 128, 128, id="greater than"), - pytest.param(128, 128, 128, id="equals"), - ], -) -def test_extract_stack_with_max_depth(depth, max_stack_depth, actual_depth): - # introduce a lambda that we'll be looking for in the stack - frame = (lambda: get_frame(depth=depth))() - - # plus 1 because we introduced a lambda intentionally that we'll - # look for in the final stack to make sure its in the right position - base_stack_depth = len(inspect.stack()) + 1 - - # increase the max_depth by the `base_stack_depth` to account - # for the extra frames pytest will add - _, frame_ids, frames = extract_stack( - frame, - LRUCache(max_size=1), - max_stack_depth=max_stack_depth + base_stack_depth, - cwd=os.getcwd(), - ) - assert len(frame_ids) == base_stack_depth + actual_depth - assert len(frames) == base_stack_depth + actual_depth - - for i in range(actual_depth): - assert frames[i]["function"] == "get_frame", i - - # index 0 contains the inner most frame on the stack, so the lamdba - # should be at index `actual_depth` - if sys.version_info >= (3, 11): - assert ( - frames[actual_depth]["function"] - == "test_extract_stack_with_max_depth.." - ), actual_depth - else: - assert frames[actual_depth]["function"] == "", actual_depth - - -@pytest.mark.parametrize( - ("frame", "depth"), - [(get_frame(depth=1), len(inspect.stack()))], -) -def test_extract_stack_with_cache(frame, depth): - # make sure cache has enough room or this test will fail - cache = LRUCache(max_size=depth) - cwd = os.getcwd() - _, _, frames1 = extract_stack(frame, cache, cwd=cwd) - _, _, frames2 = extract_stack(frame, cache, cwd=cwd) - - assert len(frames1) > 0 - assert len(frames2) > 0 - assert len(frames1) == len(frames2) - for i, (frame1, frame2) in enumerate(zip(frames1, frames2)): - # DO NOT use `==` for the assertion here since we are - # testing for identity, and using `==` would test for - # equality which would always pass since we're extract - # the same stack. - assert frame1 is frame2, i - - -def get_scheduler_threads(scheduler): - return [thread for thread in threading.enumerate() if thread.name == scheduler.name] - - -@pytest.mark.parametrize( - ("scheduler_class",), - [ - pytest.param(ThreadScheduler, id="thread scheduler"), - pytest.param( - GeventScheduler, - marks=[ - requires_gevent, - pytest.mark.skip( - reason="cannot find this thread via threading.enumerate()" - ), - ], - id="gevent scheduler", - ), - ], -) -def test_thread_scheduler_single_background_thread(scheduler_class): - scheduler = scheduler_class(frequency=1000) - - # not yet setup, no scheduler threads yet - assert len(get_scheduler_threads(scheduler)) == 0 - - scheduler.setup() - - # setup but no profiles started so still no threads - assert len(get_scheduler_threads(scheduler)) == 0 - - scheduler.ensure_running() - - # the scheduler will start always 1 thread - assert len(get_scheduler_threads(scheduler)) == 1 - - scheduler.ensure_running() - - # the scheduler still only has 1 thread - assert len(get_scheduler_threads(scheduler)) == 1 - - scheduler.teardown() - - # once finished, the thread should stop - assert len(get_scheduler_threads(scheduler)) == 0 - - -@pytest.mark.parametrize( - ("scheduler_class",), - [ - pytest.param(ThreadScheduler, id="thread scheduler"), - pytest.param( - GeventScheduler, - marks=[ - requires_gevent, - pytest.mark.skip( - reason="cannot find this thread via threading.enumerate()" - ), - ], - id="gevent scheduler", - ), - ], -) -def test_thread_scheduler_no_thread_on_shutdown(scheduler_class): - scheduler = scheduler_class(frequency=1000) - - # not yet setup, no scheduler threads yet - assert len(get_scheduler_threads(scheduler)) == 0 - - scheduler.setup() - - # setup but no profiles started so still no threads - assert len(get_scheduler_threads(scheduler)) == 0 - - # mock RuntimeError as if the 3.12 interpreter was shutting down - with mock.patch( - "threading.Thread.start", - side_effect=RuntimeError("can't create new thread at interpreter shutdown"), - ): - scheduler.ensure_running() - - assert scheduler.running is False - - # still no thread - assert len(get_scheduler_threads(scheduler)) == 0 - - scheduler.teardown() - - assert len(get_scheduler_threads(scheduler)) == 0 - - -@pytest.mark.parametrize( - ("scheduler_class",), - [ - pytest.param(ThreadScheduler, id="thread scheduler"), - pytest.param(GeventScheduler, marks=requires_gevent, id="gevent scheduler"), - ], -) -@mock.patch("sentry_sdk.profiler.transaction_profiler.MAX_PROFILE_DURATION_NS", 1) -def test_max_profile_duration_reached(scheduler_class): - sample = [ - ( - "1", - extract_stack( - get_frame(), - LRUCache(max_size=1), - cwd=os.getcwd(), - ), - ), - ] - - with scheduler_class(frequency=1000) as scheduler: - with Profile(True, 0, scheduler=scheduler) as profile: - # profile just started, it's active - assert profile.active - - # write a sample at the start time, so still active - profile.write(profile.start_ns + 0, sample) - assert profile.active - - # write a sample at max time, so still active - profile.write(profile.start_ns + 1, sample) - assert profile.active - - # write a sample PAST the max time, so now inactive - profile.write(profile.start_ns + 2, sample) - assert not profile.active - - -class NoopScheduler(Scheduler): - def setup(self) -> None: - pass - - def teardown(self) -> None: - pass - - def ensure_running(self) -> None: - pass - - -current_thread = threading.current_thread() -thread_metadata = { - str(current_thread.ident): { - "name": str(current_thread.name), - }, -} - - -sample_stacks = [ - extract_stack( - get_frame(), - LRUCache(max_size=1), - max_stack_depth=1, - cwd=os.getcwd(), - ), - extract_stack( - get_frame(), - LRUCache(max_size=1), - max_stack_depth=2, - cwd=os.getcwd(), - ), -] - - -@pytest.mark.parametrize( - ("samples", "expected"), - [ - pytest.param( - [], - { - "frames": [], - "samples": [], - "stacks": [], - "thread_metadata": thread_metadata, - }, - id="empty", - ), - pytest.param( - [(6, [("1", sample_stacks[0])])], - { - "frames": [], - "samples": [], - "stacks": [], - "thread_metadata": thread_metadata, - }, - id="single sample out of range", - ), - pytest.param( - [(0, [("1", sample_stacks[0])])], - { - "frames": [sample_stacks[0][2][0]], - "samples": [ - { - "elapsed_since_start_ns": "0", - "thread_id": "1", - "stack_id": 0, - }, - ], - "stacks": [[0]], - "thread_metadata": thread_metadata, - }, - id="single sample in range", - ), - pytest.param( - [ - (0, [("1", sample_stacks[0])]), - (1, [("1", sample_stacks[0])]), - ], - { - "frames": [sample_stacks[0][2][0]], - "samples": [ - { - "elapsed_since_start_ns": "0", - "thread_id": "1", - "stack_id": 0, - }, - { - "elapsed_since_start_ns": "1", - "thread_id": "1", - "stack_id": 0, - }, - ], - "stacks": [[0]], - "thread_metadata": thread_metadata, - }, - id="two identical stacks", - ), - pytest.param( - [ - (0, [("1", sample_stacks[0])]), - (1, [("1", sample_stacks[1])]), - ], - { - "frames": [ - sample_stacks[0][2][0], - sample_stacks[1][2][0], - ], - "samples": [ - { - "elapsed_since_start_ns": "0", - "thread_id": "1", - "stack_id": 0, - }, - { - "elapsed_since_start_ns": "1", - "thread_id": "1", - "stack_id": 1, - }, - ], - "stacks": [[0], [1, 0]], - "thread_metadata": thread_metadata, - }, - id="two different stacks", - ), - ], -) -@mock.patch("sentry_sdk.profiler.transaction_profiler.MAX_PROFILE_DURATION_NS", 5) -def test_profile_processing( - DictionaryContaining, # noqa: N803 - samples, - expected, -): - with NoopScheduler(frequency=1000) as scheduler: - with Profile(True, 0, scheduler=scheduler) as profile: - for ts, sample in samples: - # force the sample to be written at a time relative to the - # start of the profile - now = profile.start_ns + ts - profile.write(now, sample) - - processed = profile.process() - - assert processed["thread_metadata"] == DictionaryContaining( - expected["thread_metadata"] - ) - assert processed["frames"] == expected["frames"] - assert processed["stacks"] == expected["stacks"] - assert processed["samples"] == expected["samples"] - - -def test_hub_backwards_compatibility(suppress_deprecation_warnings): - hub = sentry_sdk.Hub() - - with pytest.warns(DeprecationWarning): - profile = Profile(True, 0, hub=hub) - - with pytest.warns(DeprecationWarning): - assert profile.hub is hub - - new_hub = sentry_sdk.Hub() - - with pytest.warns(DeprecationWarning): - profile.hub = new_hub - - with pytest.warns(DeprecationWarning): - assert profile.hub is new_hub - - -def test_no_warning_without_hub(): - with warnings.catch_warnings(): - warnings.simplefilter("error") - Profile(True, 0) diff --git a/tests/test_client.py b/tests/test_client.py index 78868e434a..9c6dd3670e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1322,45 +1322,6 @@ def test_error_sampler(_, sentry_init, capture_events, test_config): assert len(test_config.sampler_function_mock.call_args[0]) == 2 -@pytest.mark.parametrize( - "opt,missing_flags", - [ - # lazy mode with enable-threads, no warning - [{"enable-threads": True, "lazy-apps": True}, []], - [{"enable-threads": "true", "lazy-apps": b"1"}, []], - # preforking mode with enable-threads and py-call-uwsgi-fork-hooks, no warning - [{"enable-threads": True, "py-call-uwsgi-fork-hooks": True}, []], - [{"enable-threads": b"true", "py-call-uwsgi-fork-hooks": b"on"}, []], - # lazy mode, no enable-threads, warning - [{"lazy-apps": True}, ["--enable-threads"]], - [{"enable-threads": b"false", "lazy-apps": True}, ["--enable-threads"]], - [{"enable-threads": b"0", "lazy": True}, ["--enable-threads"]], - # preforking mode, no enable-threads or py-call-uwsgi-fork-hooks, warning - [{}, ["--enable-threads", "--py-call-uwsgi-fork-hooks"]], - [{"processes": b"2"}, ["--enable-threads", "--py-call-uwsgi-fork-hooks"]], - [{"enable-threads": True}, ["--py-call-uwsgi-fork-hooks"]], - [{"enable-threads": b"1"}, ["--py-call-uwsgi-fork-hooks"]], - [ - {"enable-threads": b"false"}, - ["--enable-threads", "--py-call-uwsgi-fork-hooks"], - ], - [{"py-call-uwsgi-fork-hooks": True}, ["--enable-threads"]], - ], -) -def test_uwsgi_warnings(sentry_init, recwarn, opt, missing_flags): - uwsgi = mock.MagicMock() - uwsgi.opt = opt - with mock.patch.dict("sys.modules", uwsgi=uwsgi): - sentry_init(profiles_sample_rate=1.0) - if missing_flags: - assert len(recwarn) == 1 - record = recwarn.pop() - for flag in missing_flags: - assert flag in str(record.message) - else: - assert not recwarn - - class TestSpanClientReports: """ Tests for client reports related to spans. diff --git a/tests/test_envelope.py b/tests/test_envelope.py index 5549cc6e36..f6a7c4b541 100644 --- a/tests/test_envelope.py +++ b/tests/test_envelope.py @@ -250,7 +250,6 @@ def test_envelope_item_data_category_mapping(): ("session", "session"), ("attachment", "attachment"), ("client_report", "internal"), - ("profile", "profile"), ("profile_chunk", "profile_chunk"), ("check_in", "monitor"), ("unknown_type", "default"), From 2fe02f42af38c84511c4e5a5c772dd5feb643d25 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 29 Jul 2026 12:31:00 +0200 Subject: [PATCH 2/5] migration --- MIGRATION_GUIDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 00d0f3ac70..664ba29e28 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -13,6 +13,8 @@ Looking to upgrade from Sentry SDK 2.x to 3.x? Here's a comprehensive list of wh ## Removed +- Transaction profiling and related code was removed. + ## Deprecated From 61fa799d544ab487c8d2bd4c670df2a4b3123e70 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 29 Jul 2026 13:57:12 +0200 Subject: [PATCH 3/5] revert test removal --- tests/test_client.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 9c6dd3670e..1384bb09ff 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1322,6 +1322,45 @@ def test_error_sampler(_, sentry_init, capture_events, test_config): assert len(test_config.sampler_function_mock.call_args[0]) == 2 +@pytest.mark.parametrize( + "opt,missing_flags", + [ + # lazy mode with enable-threads, no warning + [{"enable-threads": True, "lazy-apps": True}, []], + [{"enable-threads": "true", "lazy-apps": b"1"}, []], + # preforking mode with enable-threads and py-call-uwsgi-fork-hooks, no warning + [{"enable-threads": True, "py-call-uwsgi-fork-hooks": True}, []], + [{"enable-threads": b"true", "py-call-uwsgi-fork-hooks": b"on"}, []], + # lazy mode, no enable-threads, warning + [{"lazy-apps": True}, ["--enable-threads"]], + [{"enable-threads": b"false", "lazy-apps": True}, ["--enable-threads"]], + [{"enable-threads": b"0", "lazy": True}, ["--enable-threads"]], + # preforking mode, no enable-threads or py-call-uwsgi-fork-hooks, warning + [{}, ["--enable-threads", "--py-call-uwsgi-fork-hooks"]], + [{"processes": b"2"}, ["--enable-threads", "--py-call-uwsgi-fork-hooks"]], + [{"enable-threads": True}, ["--py-call-uwsgi-fork-hooks"]], + [{"enable-threads": b"1"}, ["--py-call-uwsgi-fork-hooks"]], + [ + {"enable-threads": b"false"}, + ["--enable-threads", "--py-call-uwsgi-fork-hooks"], + ], + [{"py-call-uwsgi-fork-hooks": True}, ["--enable-threads"]], + ], +) +def test_uwsgi_warnings(sentry_init, recwarn, opt, missing_flags): + uwsgi = mock.MagicMock() + uwsgi.opt = opt + with mock.patch.dict("sys.modules", uwsgi=uwsgi): + sentry_init() + if missing_flags: + assert len(recwarn) == 1 + record = recwarn.pop() + for flag in missing_flags: + assert flag in str(record.message) + else: + assert not recwarn + + class TestSpanClientReports: """ Tests for client reports related to spans. From c9e5fd2b79d0e5ca658ae8f4601c2ac7a960d81d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 29 Jul 2026 14:08:03 +0200 Subject: [PATCH 4/5] remove from apidocs --- docs/apidocs.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/apidocs.rst b/docs/apidocs.rst index a3c8a6e150..043aef93a8 100644 --- a/docs/apidocs.rst +++ b/docs/apidocs.rst @@ -32,9 +32,6 @@ API Docs .. autoclass:: sentry_sdk.tracing.Span :members: -.. autoclass:: sentry_sdk.profiler.transaction_profiler.Profile - :members: - .. autoclass:: sentry_sdk.session.Session :members: From b129c8d1b3a15d1a16f486e11cc13e79bceaadc4 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 29 Jul 2026 15:08:09 +0200 Subject: [PATCH 5/5] remove sleep profiler mode --- sentry_sdk/_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index a49b33d77b..182a364130 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -427,7 +427,7 @@ class DataCollection(TypedDict): SessionStatus = Literal["ok", "exited", "crashed", "abnormal"] ContinuousProfilerMode = Literal["thread", "gevent", "unknown"] - ProfilerMode = Union[ContinuousProfilerMode, Literal["sleep"]] + ProfilerMode = Union[ContinuousProfilerMode] MonitorConfigScheduleType = Literal["crontab", "interval"] MonitorConfigScheduleUnit = Literal[