Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
67a5572
Work-in-progress integration of profiler into WSGI
szokeasaurusrex Jun 27, 2022
e591c90
Deleted dead code
szokeasaurusrex Jun 28, 2022
1da4c76
Sentry-compatible JSON representation of profile
szokeasaurusrex Jun 29, 2022
cfa6fa9
Changed profile name to "main"
szokeasaurusrex Jun 30, 2022
be558ec
Added option for conditionally enabling profiling
szokeasaurusrex Jun 30, 2022
7e8e9cc
Adding transaction name to profile
szokeasaurusrex Jun 30, 2022
596f474
Save only the raw file name
szokeasaurusrex Jun 30, 2022
d4b309b
Merge branch 'master' into profiling
szokeasaurusrex Jul 6, 2022
d1755a1
Memory optimized, no overwriting profiles
szokeasaurusrex Jul 6, 2022
cff0127
Changed JSON output format
szokeasaurusrex Jul 8, 2022
7b97f69
Upload profile data to Sentry
szokeasaurusrex Jul 8, 2022
3b5f032
Stop printing profiles to JSON file locally
szokeasaurusrex Jul 8, 2022
8da6d10
Only save profile to event if profiling enabled
szokeasaurusrex Jul 11, 2022
78e6c1d
Fix bug: error when JSON generated no sample
szokeasaurusrex Jul 11, 2022
ccd2e45
Profile sent as separate envelope item
szokeasaurusrex Jul 11, 2022
68d77e3
Fix bug crashing program when no profile collected
szokeasaurusrex Jul 12, 2022
cb8ea79
Changed licensing notice in profiler.py
szokeasaurusrex Jul 13, 2022
ac41502
Merge branch 'master' into profiling
szokeasaurusrex Jul 15, 2022
5ba799f
Changes from Neel's code review feedback
szokeasaurusrex Jul 15, 2022
01599e8
Merge branch 'profiling' into profiling-new-data-format
szokeasaurusrex Jul 15, 2022
b418ac0
Code review changes for Neel
szokeasaurusrex Jul 15, 2022
2ace5fc
Deleted dead sample_weights method
szokeasaurusrex Jul 20, 2022
c475273
Fixed linter errors
szokeasaurusrex Jul 20, 2022
fb8989d
Fixed linter error that was missed in previous commit
szokeasaurusrex Jul 20, 2022
3797c39
Ran pre-commit hooks against the code
szokeasaurusrex Jul 20, 2022
9c84589
Fix MYPY errors
szokeasaurusrex Jul 20, 2022
d387cb3
Merge branch 'master' into profiling
sl0thentr0py Jul 21, 2022
fb58394
Added metadata to profile
szokeasaurusrex Jul 21, 2022
f8495a9
merge
szokeasaurusrex Jul 21, 2022
bc53830
Merge branch 'master' into profiling
sl0thentr0py Jul 22, 2022
b95f46c
enable_profiling flag moved to experiment
szokeasaurusrex Jul 22, 2022
8944fa6
Merge branch 'profiling' of https://github.com/getsentry/sentry-pytho…
szokeasaurusrex Jul 22, 2022
444de7f
Merge branch 'master' into profiling
szokeasaurusrex Jul 22, 2022
9d6a494
Added integration tests
szokeasaurusrex Jul 22, 2022
6c8fc38
Fixed thread ID call for Python 2.7
szokeasaurusrex Jul 25, 2022
1859525
Fixed MYPY error
szokeasaurusrex Jul 25, 2022
a93f10c
Add some comments, suppress missing import
szokeasaurusrex Jul 25, 2022
07e1f4b
Fixed Python2 compatibility issues
szokeasaurusrex Jul 25, 2022
d1ca7d9
Set version_code field to empty string
szokeasaurusrex Jul 25, 2022
c1f0b64
Move context manager to profiler
sl0thentr0py Jul 28, 2022
837c409
Factor out profiling hceck
sl0thentr0py Jul 28, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions sentry_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,10 @@ def capture_event(
envelope = Envelope(headers=headers)

if is_transaction:
if "profile" in event_opt:
event_opt["profile"]["transaction_id"] = event_opt["event_id"]
event_opt["profile"]["version_name"] = event_opt["release"]
envelope.add_profile(event_opt.pop("profile"))
envelope.add_transaction(event_opt)
else:
envelope.add_event(event_opt)
Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"smart_transaction_trimming": Optional[bool],
"propagate_tracestate": Optional[bool],
"custom_measurements": Optional[bool],
"enable_profiling": Optional[bool],
},
total=False,
)
Expand Down
6 changes: 6 additions & 0 deletions sentry_sdk/envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ def add_transaction(
# type: (...) -> None
self.add_item(Item(payload=PayloadRef(json=transaction), type="transaction"))

def add_profile(
self, profile # type: Any
):
# type: (...) -> None
self.add_item(Item(payload=PayloadRef(json=profile), type="profile"))

def add_session(
self, session # type: Union[Session, Any]
):
Expand Down
3 changes: 2 additions & 1 deletion sentry_sdk/integrations/wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from sentry_sdk.tracing import Transaction
from sentry_sdk.sessions import auto_session_tracking
from sentry_sdk.integrations._wsgi_common import _filter_headers
from sentry_sdk.profiler import profiling

from sentry_sdk._types import MYPY

Expand Down Expand Up @@ -127,7 +128,7 @@ def __call__(self, environ, start_response):

with hub.start_transaction(
transaction, custom_sampling_context={"wsgi_environ": environ}
):
), profiling(transaction, hub):
try:
rv = self.app(
environ,
Expand Down
212 changes: 212 additions & 0 deletions sentry_sdk/profiler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"""
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 signal
import time
from contextlib import contextmanager

import sentry_sdk
from sentry_sdk._compat import PY2
from sentry_sdk.utils import logger

if PY2:
import thread # noqa
else:
import threading

from sentry_sdk._types import MYPY

if MYPY:
import typing
from typing import Generator
from typing import Optional
import sentry_sdk.tracing


if PY2:

def thread_id():
# type: () -> int
return thread.get_ident()

def nanosecond_time():
# type: () -> int
return int(time.clock() * 1e9)

else:

def thread_id():
# type: () -> int
return threading.get_ident()

def nanosecond_time():
# type: () -> int
return int(time.perf_counter() * 1e9)


class FrameData:
def __init__(self, frame):
# type: (typing.Any) -> None
self.function_name = frame.f_code.co_name
self.module = frame.f_globals["__name__"]

# Depending on Python version, frame.f_code.co_filename either stores just the file name or the entire absolute path.
self.file_name = frame.f_code.co_filename
self.line_number = frame.f_code.co_firstlineno

@property
def _attribute_tuple(self):
# type: () -> typing.Tuple[str, str, str, int]
"""Returns a tuple of the attributes used in comparison"""
return (self.function_name, self.module, self.file_name, self.line_number)

def __eq__(self, other):
# type: (typing.Any) -> bool
if isinstance(other, FrameData):
return self._attribute_tuple == other._attribute_tuple
return False

def __hash__(self):
# type: () -> int
return hash(self._attribute_tuple)


class StackSample:
def __init__(self, top_frame, profiler_start_time, frame_indices):
# type: (typing.Any, int, typing.Dict[FrameData, int]) -> None
self.sample_time = nanosecond_time() - profiler_start_time
self.stack = [] # type: typing.List[int]
self._add_all_frames(top_frame, frame_indices)

def _add_all_frames(self, top_frame, frame_indices):
# type: (typing.Any, typing.Dict[FrameData, int]) -> None
frame = top_frame
while frame is not None:
frame_data = FrameData(frame)
if frame_data not in frame_indices:
frame_indices[frame_data] = len(frame_indices)
self.stack.append(frame_indices[frame_data])
frame = frame.f_back
self.stack = list(reversed(self.stack))


class Sampler(object):
"""
A simple stack sampler for low-overhead CPU profiling: samples the call
stack every `interval` seconds and keeps track of counts by frame. Because
this uses signals, it only works on the main thread.
"""

def __init__(self, transaction, interval=0.01):
# type: (sentry_sdk.tracing.Transaction, float) -> None
self.interval = interval
self.stack_samples = [] # type: typing.List[StackSample]
self._frame_indices = dict() # type: typing.Dict[FrameData, int]
self._transaction = transaction
self.duration = 0 # This value will only be correct after the profiler has been started and stopped
transaction._profile = self

def __enter__(self):
# type: () -> None
self.start()

def __exit__(self, *_):
# type: (*typing.List[typing.Any]) -> None
self.stop()

def start(self):
# type: () -> None
self._start_time = nanosecond_time()
self.stack_samples = []
self._frame_indices = dict()
try:
signal.signal(signal.SIGVTALRM, self._sample)
except ValueError:
logger.error(
"Profiler failed to run because it was started from a non-main thread"
)
return

signal.setitimer(signal.ITIMER_VIRTUAL, self.interval)
atexit.register(self.stop)

def _sample(self, _, frame):
# type: (typing.Any, typing.Any) -> None
self.stack_samples.append(
StackSample(frame, self._start_time, self._frame_indices)
)
signal.setitimer(signal.ITIMER_VIRTUAL, self.interval)

def to_json(self):
# type: () -> typing.Any
"""
Exports this object to a JSON format compatible with Sentry's profiling visualizer.
Returns dictionary which can be serialized to JSON.
"""
return {
"samples": [
{
"frames": sample.stack,
"relative_timestamp_ns": sample.sample_time,
"thread_id": thread_id(),
}
for sample in self.stack_samples
],
"frames": [
{
"name": frame.function_name,
"file": frame.file_name,
"line": frame.line_number,
}
for frame in self.frame_list()
],
}

def frame_list(self):
# type: () -> typing.List[FrameData]
# Build frame array from the frame indices
frames = [None] * len(self._frame_indices) # type: typing.List[typing.Any]
for frame, index in self._frame_indices.items():
frames[index] = frame
return frames

def stop(self):
# type: () -> None
self.duration = nanosecond_time() - self._start_time
signal.setitimer(signal.ITIMER_VIRTUAL, 0)

@property
def transaction_name(self):
# type: () -> str
return self._transaction.name


def has_profiling_enabled(hub=None):
# type: (Optional[sentry_sdk.Hub]) -> bool
if hub is None:
hub = sentry_sdk.Hub.current

options = hub.client and hub.client.options
return bool(options and options["_experiments"].get("enable_profiling"))


@contextmanager
def profiling(transaction, hub=None):
# type: (sentry_sdk.tracing.Transaction, Optional[sentry_sdk.Hub]) -> Generator[None, None, None]
if has_profiling_enabled(hub):
with Sampler(transaction):
yield
else:
yield
26 changes: 26 additions & 0 deletions sentry_sdk/tracing.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import uuid
import random
import time
import platform

from datetime import datetime, timedelta

import sentry_sdk

from sentry_sdk.profiler import has_profiling_enabled
from sentry_sdk.utils import logger
from sentry_sdk._types import MYPY

Expand All @@ -19,6 +21,7 @@
from typing import List
from typing import Tuple
from typing import Iterator
from sentry_sdk.profiler import Sampler

from sentry_sdk._types import SamplingContext, MeasurementUnit

Expand Down Expand Up @@ -533,6 +536,7 @@ class Transaction(Span):
# tracestate data from other vendors, of the form `dogs=yes,cats=maybe`
"_third_party_tracestate",
"_measurements",
"_profile",
"_baggage",
)

Expand Down Expand Up @@ -566,6 +570,7 @@ def __init__(
self._sentry_tracestate = sentry_tracestate
self._third_party_tracestate = third_party_tracestate
self._measurements = {} # type: Dict[str, Any]
self._profile = None # type: Optional[Sampler]
self._baggage = baggage

def __repr__(self):
Expand Down Expand Up @@ -658,6 +663,27 @@ def finish(self, hub=None):
"spans": finished_spans,
}

if (
has_profiling_enabled(hub)
and hub.client is not None
and self._profile is not None
):
event["profile"] = {
"device_os_name": platform.system(),
"device_os_version": platform.release(),
"duration_ns": self._profile.duration,
"environment": hub.client.options["environment"],
"platform": "python",
"platform_version": platform.python_version(),
"profile_id": uuid.uuid4().hex,
"profile": self._profile.to_json(),
"trace_id": self.trace_id,
"transaction_id": None, # Gets added in client.py
"transaction_name": self.name,
"version_code": "", # TODO: Determine appropriate value. Currently set to empty string so profile will not get rejected.
"version_name": None, # Gets added in client.py
}

if has_custom_measurements_enabled():
event["measurements"] = self._measurements

Expand Down
40 changes: 40 additions & 0 deletions tests/integrations/wsgi/test_wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,43 @@ def sample_app(environ, start_response):
assert session_aggregates[0]["exited"] == 2
assert session_aggregates[0]["crashed"] == 1
assert len(session_aggregates) == 1


def test_profile_sent_when_profiling_enabled(capture_envelopes, sentry_init):
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={"enable_profiling": True})
app = SentryWsgiMiddleware(test_app)
envelopes = capture_envelopes()

client = Client(app)
client.get("/")

profile_sent = False
for item in envelopes[0].items:
if item.headers["type"] == "profile":
profile_sent = True
break
assert profile_sent


def test_profile_not_sent_when_profiling_disabled(capture_envelopes, sentry_init):
def test_app(environ, start_response):
start_response("200 OK", [])
return ["Go get the ball! Good dog!"]

sentry_init(traces_sample_rate=1.0)
app = SentryWsgiMiddleware(test_app)
envelopes = capture_envelopes()

client = Client(app)
client.get("/")

profile_sent = False
for item in envelopes[0].items:
if item.headers["type"] == "profile":
profile_sent = True
break
assert not profile_sent