Skip to content

Commit 1db0411

Browse files
committed
feat(profiling): Continuous profiling lifecycle
This introduces auto lifecycle setting for continuous profiling to only profile while there is an active transaction. This replaces the experimental auto start setting.
1 parent 797e82f commit 1db0411

File tree

5 files changed

+262
-34
lines changed

5 files changed

+262
-34
lines changed

sentry_sdk/consts.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class CompressionAlgo(Enum):
3838
from typing import Any
3939
from typing import Sequence
4040
from typing import Tuple
41+
from typing_extensions import Literal
4142
from typing_extensions import TypedDict
4243

4344
from sentry_sdk._types import (
@@ -528,6 +529,7 @@ def __init__(
528529
profiles_sample_rate=None, # type: Optional[float]
529530
profiles_sampler=None, # type: Optional[TracesSampler]
530531
profiler_mode=None, # type: Optional[ProfilerMode]
532+
profile_lifecycle="manual", # type: Literal["manual", "auto"]
531533
profile_session_sample_rate=None, # type: Optional[float]
532534
auto_enabling_integrations=True, # type: bool
533535
disabled_integrations=None, # type: Optional[Sequence[sentry_sdk.integrations.Integration]]

sentry_sdk/profiler/continuous_profiler.py

Lines changed: 133 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import threading
66
import time
77
import uuid
8+
from collections import deque
89
from datetime import datetime, timezone
910

1011
from sentry_sdk.consts import VERSION
@@ -27,6 +28,7 @@
2728
if TYPE_CHECKING:
2829
from typing import Any
2930
from typing import Callable
31+
from typing import Deque
3032
from typing import Dict
3133
from typing import List
3234
from typing import Optional
@@ -120,6 +122,9 @@ def setup_continuous_profiler(options, sdk_info, capture_func):
120122

121123
def try_autostart_continuous_profiler():
122124
# type: () -> None
125+
126+
# TODO: deprecate this as it'll be replaced by the auto lifecycle option
127+
123128
if _scheduler is None:
124129
return
125130

@@ -129,6 +134,22 @@ def try_autostart_continuous_profiler():
129134
_scheduler.manual_start()
130135

131136

137+
def try_profile_lifecycle_auto_start():
138+
# type: () -> bool
139+
if _scheduler is None:
140+
return False
141+
142+
return _scheduler.auto_start()
143+
144+
145+
def try_profile_lifecycle_auto_stop():
146+
# type: () -> None
147+
if _scheduler is None:
148+
return
149+
150+
_scheduler.auto_stop()
151+
152+
132153
def start_profiler():
133154
# type: () -> None
134155
if _scheduler is None:
@@ -179,16 +200,22 @@ def __init__(self, frequency, options, sdk_info, capture_func):
179200
self.options = options
180201
self.sdk_info = sdk_info
181202
self.capture_func = capture_func
203+
204+
self.lifecycle = self.options.get("profile_lifecycle")
205+
profile_session_sample_rate = self.options.get("profile_session_sample_rate")
206+
self.sampled = determine_profile_session_sampling_decision(
207+
profile_session_sample_rate
208+
)
209+
182210
self.sampler = self.make_sampler()
183211
self.buffer = None # type: Optional[ProfileBuffer]
184212
self.pid = None # type: Optional[int]
185213

186214
self.running = False
187215

188-
profile_session_sample_rate = self.options.get("profile_session_sample_rate")
189-
self.sampled = determine_profile_session_sampling_decision(
190-
profile_session_sample_rate
191-
)
216+
self.active_spans = 0
217+
self.started_spans = deque(maxlen=128) # type: Deque[None]
218+
self.finished_spans = deque(maxlen=128) # type: Deque[None]
192219

193220
def is_auto_start_enabled(self):
194221
# type: () -> bool
@@ -207,15 +234,45 @@ def is_auto_start_enabled(self):
207234

208235
return experiments.get("continuous_profiling_auto_start")
209236

237+
def auto_start(self):
238+
# type: () -> bool
239+
if not self.sampled:
240+
return False
241+
242+
if self.lifecycle != "auto":
243+
return False
244+
245+
logger.debug("[Profiling] Auto starting profiler")
246+
247+
self.started_spans.append(None)
248+
self.ensure_running()
249+
250+
return True
251+
252+
def auto_stop(self):
253+
# type: () -> None
254+
if self.lifecycle != "auto":
255+
return
256+
257+
logger.debug("[Profiling] Auto stopping profiler")
258+
259+
self.finished_spans.append(None)
260+
210261
def manual_start(self):
211262
# type: () -> None
212263
if not self.sampled:
213264
return
214265

266+
if self.lifecycle != "manual":
267+
return
268+
215269
self.ensure_running()
216270

217271
def manual_stop(self):
218272
# type: () -> None
273+
if self.lifecycle != "manual":
274+
return
275+
219276
self.teardown()
220277

221278
def ensure_running(self):
@@ -249,28 +306,77 @@ def make_sampler(self):
249306

250307
cache = LRUCache(max_size=256)
251308

252-
def _sample_stack(*args, **kwargs):
253-
# type: (*Any, **Any) -> None
254-
"""
255-
Take a sample of the stack on all the threads in the process.
256-
This should be called at a regular interval to collect samples.
257-
"""
258-
259-
ts = now()
260-
261-
try:
262-
sample = [
263-
(str(tid), extract_stack(frame, cache, cwd))
264-
for tid, frame in sys._current_frames().items()
265-
]
266-
except AttributeError:
267-
# For some reason, the frame we get doesn't have certain attributes.
268-
# When this happens, we abandon the current sample as it's bad.
269-
capture_internal_exception(sys.exc_info())
270-
return
271-
272-
if self.buffer is not None:
273-
self.buffer.write(ts, sample)
309+
if self.lifecycle == "auto":
310+
311+
def _sample_stack(*args, **kwargs):
312+
# type: (*Any, **Any) -> None
313+
"""
314+
Take a sample of the stack on all the threads in the process.
315+
This should be called at a regular interval to collect samples.
316+
"""
317+
318+
if (
319+
not self.active_spans
320+
and not self.started_spans
321+
and not self.finished_spans
322+
):
323+
self.running = False
324+
return
325+
326+
started_spans = len(self.started_spans)
327+
finished_spans = len(self.finished_spans)
328+
329+
ts = now()
330+
331+
try:
332+
sample = [
333+
(str(tid), extract_stack(frame, cache, cwd))
334+
for tid, frame in sys._current_frames().items()
335+
]
336+
except AttributeError:
337+
# For some reason, the frame we get doesn't have certain attributes.
338+
# When this happens, we abandon the current sample as it's bad.
339+
capture_internal_exception(sys.exc_info())
340+
return
341+
342+
for _ in range(started_spans):
343+
self.started_spans.popleft()
344+
345+
for _ in range(finished_spans):
346+
self.finished_spans.popleft()
347+
348+
self.active_spans = self.active_spans + started_spans - finished_spans
349+
350+
if self.buffer is None:
351+
self.reset_buffer()
352+
353+
if self.buffer is not None:
354+
self.buffer.write(ts, sample)
355+
356+
else:
357+
358+
def _sample_stack(*args, **kwargs):
359+
# type: (*Any, **Any) -> None
360+
"""
361+
Take a sample of the stack on all the threads in the process.
362+
This should be called at a regular interval to collect samples.
363+
"""
364+
365+
ts = now()
366+
367+
try:
368+
sample = [
369+
(str(tid), extract_stack(frame, cache, cwd))
370+
for tid, frame in sys._current_frames().items()
371+
]
372+
except AttributeError:
373+
# For some reason, the frame we get doesn't have certain attributes.
374+
# When this happens, we abandon the current sample as it's bad.
375+
capture_internal_exception(sys.exc_info())
376+
return
377+
378+
if self.buffer is not None:
379+
self.buffer.write(ts, sample)
274380

275381
return _sample_stack
276382

@@ -294,6 +400,7 @@ def run(self):
294400

295401
if self.buffer is not None:
296402
self.buffer.flush()
403+
self.buffer = None
297404

298405

299406
class ThreadContinuousScheduler(ContinuousScheduler):

sentry_sdk/scope.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
from sentry_sdk.attachments import Attachment
1313
from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, FALSE_VALUES, INSTRUMENTER
1414
from sentry_sdk.feature_flags import FlagBuffer, DEFAULT_FLAG_CAPACITY
15-
from sentry_sdk.profiler.continuous_profiler import try_autostart_continuous_profiler
15+
from sentry_sdk.profiler.continuous_profiler import (
16+
get_profiler_id,
17+
try_autostart_continuous_profiler,
18+
try_profile_lifecycle_auto_start,
19+
)
1620
from sentry_sdk.profiler.transaction_profiler import Profile
1721
from sentry_sdk.session import Session
1822
from sentry_sdk.tracing_utils import (
@@ -1051,6 +1055,14 @@ def start_transaction(
10511055

10521056
transaction._profile = profile
10531057

1058+
transaction._started_profile_lifecycle = try_profile_lifecycle_auto_start()
1059+
1060+
# Typically, the profiler is set when the transaction is created. But when
1061+
# using the auto lifecycle, the profiler isn't running when the first
1062+
# transaction is started. So make sure we update the profiler id on it.
1063+
if transaction._started_profile_lifecycle:
1064+
transaction.set_profiler_id(get_profiler_id())
1065+
10541066
# we don't bother to keep spans if we already know we're not going to
10551067
# send the transaction
10561068
max_spans = (client.options["_experiments"].get("max_spans")) or 1000

sentry_sdk/tracing.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55

66
import sentry_sdk
77
from sentry_sdk.consts import INSTRUMENTER, SPANSTATUS, SPANDATA
8-
from sentry_sdk.profiler.continuous_profiler import get_profiler_id
8+
from sentry_sdk.profiler.continuous_profiler import (
9+
get_profiler_id,
10+
try_profile_lifecycle_auto_stop,
11+
)
912
from sentry_sdk.utils import (
1013
get_current_thread_meta,
1114
is_valid_sample_rate,
@@ -268,6 +271,7 @@ class Span:
268271
"scope",
269272
"origin",
270273
"name",
274+
"_started_profile_lifecycle",
271275
)
272276

273277
def __init__(
@@ -790,6 +794,7 @@ def __init__( # type: ignore[misc]
790794
self._profile = (
791795
None
792796
) # type: Optional[sentry_sdk.profiler.transaction_profiler.Profile]
797+
self._started_profile_lifecycle = False # type: bool
793798
self._baggage = baggage
794799

795800
def __repr__(self):
@@ -842,6 +847,9 @@ def __exit__(self, ty, value, tb):
842847
if self._profile is not None:
843848
self._profile.__exit__(ty, value, tb)
844849

850+
if self._started_profile_lifecycle:
851+
try_profile_lifecycle_auto_stop()
852+
845853
super().__exit__(ty, value, tb)
846854

847855
@property

0 commit comments

Comments
 (0)