Skip to content

Commit 30f72a3

Browse files
Django caching instrumentation update (#3009)
This adds more data to the cache spans and makes adding the cache item size optional. This implements parts of following spec https://develop.sentry.dev/sdk/performance/modules/cache/ --------- Co-authored-by: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com>
1 parent ec23396 commit 30f72a3

File tree

9 files changed

+744
-303
lines changed

9 files changed

+744
-303
lines changed

sentry_sdk/consts.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,24 @@ class SPANDATA:
240240
Example: 58
241241
"""
242242

243+
CACHE_KEY = "cache.key"
244+
"""
245+
The key of the requested data.
246+
Example: template.cache.some_item.867da7e2af8e6b2f3aa7213a4080edb3
247+
"""
248+
249+
NETWORK_PEER_ADDRESS = "network.peer.address"
250+
"""
251+
Peer address of the network connection - IP address or Unix domain socket name.
252+
Example: 10.1.2.80, /tmp/my.sock, localhost
253+
"""
254+
255+
NETWORK_PEER_PORT = "network.peer.port"
256+
"""
257+
Peer port number of the network connection.
258+
Example: 6379
259+
"""
260+
243261
HTTP_QUERY = "http.query"
244262
"""
245263
The Query string present in the URL.
@@ -349,7 +367,8 @@ class SPANDATA:
349367

350368
class OP:
351369
ANTHROPIC_MESSAGES_CREATE = "ai.messages.create.anthropic"
352-
CACHE_GET_ITEM = "cache.get_item"
370+
CACHE_GET = "cache.get"
371+
CACHE_SET = "cache.set"
353372
COHERE_CHAT_COMPLETIONS_CREATE = "ai.chat_completions.create.cohere"
354373
COHERE_EMBEDDINGS_CREATE = "ai.embeddings.create.cohere"
355374
DB = "db"

sentry_sdk/integrations/django/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ def is_authenticated(request_user):
104104

105105

106106
class DjangoIntegration(Integration):
107+
"""
108+
Auto instrument a Django application.
109+
110+
:param transaction_style: How to derive transaction names. Either `"function_name"` or `"url"`. Defaults to `"url"`.
111+
:param middleware_spans: Whether to create spans for middleware. Defaults to `True`.
112+
:param signals_spans: Whether to create spans for signals. Defaults to `True`.
113+
:param signals_denylist: A list of signals to ignore when creating spans.
114+
:param cache_spans: Whether to create spans for cache operations. Defaults to `False`.
115+
"""
116+
107117
identifier = "django"
108118

109119
transaction_style = ""
@@ -128,10 +138,12 @@ def __init__(
128138
)
129139
self.transaction_style = transaction_style
130140
self.middleware_spans = middleware_spans
141+
131142
self.signals_spans = signals_spans
132-
self.cache_spans = cache_spans
133143
self.signals_denylist = signals_denylist or []
134144

145+
self.cache_spans = cache_spans
146+
135147
@staticmethod
136148
def setup_once():
137149
# type: () -> None

sentry_sdk/integrations/django/caching.py

Lines changed: 107 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,151 @@
11
import functools
22
from typing import TYPE_CHECKING
3+
from urllib3.util import parse_url as urlparse
34

45
from django import VERSION as DJANGO_VERSION
56
from django.core.cache import CacheHandler
67

78
import sentry_sdk
89
from sentry_sdk.consts import OP, SPANDATA
9-
from sentry_sdk.utils import ensure_integration_enabled
10+
from sentry_sdk.utils import (
11+
SENSITIVE_DATA_SUBSTITUTE,
12+
capture_internal_exceptions,
13+
ensure_integration_enabled,
14+
)
1015

1116

1217
if TYPE_CHECKING:
1318
from typing import Any
1419
from typing import Callable
20+
from typing import Optional
1521

1622

1723
METHODS_TO_INSTRUMENT = [
24+
"set",
25+
"set_many",
1826
"get",
1927
"get_many",
2028
]
2129

2230

23-
def _get_span_description(method_name, args, kwargs):
24-
# type: (str, Any, Any) -> str
25-
description = "{} ".format(method_name)
31+
def _get_key(args, kwargs):
32+
# type: (list[Any], dict[str, Any]) -> str
33+
key = ""
2634

2735
if args is not None and len(args) >= 1:
28-
description += str(args[0])
36+
key = args[0]
2937
elif kwargs is not None and "key" in kwargs:
30-
description += str(kwargs["key"])
38+
key = kwargs["key"]
39+
40+
if isinstance(key, dict):
41+
# Do not leak sensitive data
42+
# `set_many()` has a dict {"key1": "value1", "key2": "value2"} as first argument.
43+
# Those values could include sensitive data so we replace them with a placeholder
44+
key = {x: SENSITIVE_DATA_SUBSTITUTE for x in key}
45+
46+
return str(key)
47+
3148

32-
return description
49+
def _get_span_description(method_name, args, kwargs):
50+
# type: (str, list[Any], dict[str, Any]) -> str
51+
return _get_key(args, kwargs)
3352

3453

35-
def _patch_cache_method(cache, method_name):
36-
# type: (CacheHandler, str) -> None
54+
def _patch_cache_method(cache, method_name, address, port):
55+
# type: (CacheHandler, str, Optional[str], Optional[int]) -> None
3756
from sentry_sdk.integrations.django import DjangoIntegration
3857

3958
original_method = getattr(cache, method_name)
4059

4160
@ensure_integration_enabled(DjangoIntegration, original_method)
42-
def _instrument_call(cache, method_name, original_method, args, kwargs):
43-
# type: (CacheHandler, str, Callable[..., Any], Any, Any) -> Any
61+
def _instrument_call(
62+
cache, method_name, original_method, args, kwargs, address, port
63+
):
64+
# type: (CacheHandler, str, Callable[..., Any], list[Any], dict[str, Any], Optional[str], Optional[int]) -> Any
65+
is_set_operation = method_name.startswith("set")
66+
is_get_operation = not is_set_operation
67+
68+
op = OP.CACHE_SET if is_set_operation else OP.CACHE_GET
4469
description = _get_span_description(method_name, args, kwargs)
4570

46-
with sentry_sdk.start_span(
47-
op=OP.CACHE_GET_ITEM, description=description
48-
) as span:
71+
with sentry_sdk.start_span(op=op, description=description) as span:
4972
value = original_method(*args, **kwargs)
5073

51-
if value:
52-
span.set_data(SPANDATA.CACHE_HIT, True)
53-
54-
size = len(str(value))
55-
span.set_data(SPANDATA.CACHE_ITEM_SIZE, size)
56-
57-
else:
58-
span.set_data(SPANDATA.CACHE_HIT, False)
74+
with capture_internal_exceptions():
75+
if address is not None:
76+
span.set_data(SPANDATA.NETWORK_PEER_ADDRESS, address)
77+
78+
if port is not None:
79+
span.set_data(SPANDATA.NETWORK_PEER_PORT, port)
80+
81+
key = _get_key(args, kwargs)
82+
if key != "":
83+
span.set_data(SPANDATA.CACHE_KEY, key)
84+
85+
item_size = None
86+
if is_get_operation:
87+
if value:
88+
item_size = len(str(value))
89+
span.set_data(SPANDATA.CACHE_HIT, True)
90+
else:
91+
span.set_data(SPANDATA.CACHE_HIT, False)
92+
else:
93+
try:
94+
# 'set' command
95+
item_size = len(str(args[1]))
96+
except IndexError:
97+
# 'set_many' command
98+
item_size = len(str(args[0]))
99+
100+
if item_size is not None:
101+
span.set_data(SPANDATA.CACHE_ITEM_SIZE, item_size)
59102

60103
return value
61104

62105
@functools.wraps(original_method)
63106
def sentry_method(*args, **kwargs):
64107
# type: (*Any, **Any) -> Any
65-
return _instrument_call(cache, method_name, original_method, args, kwargs)
108+
return _instrument_call(
109+
cache, method_name, original_method, args, kwargs, address, port
110+
)
66111

67112
setattr(cache, method_name, sentry_method)
68113

69114

70-
def _patch_cache(cache):
71-
# type: (CacheHandler) -> None
115+
def _patch_cache(cache, address=None, port=None):
116+
# type: (CacheHandler, Optional[str], Optional[int]) -> None
72117
if not hasattr(cache, "_sentry_patched"):
73118
for method_name in METHODS_TO_INSTRUMENT:
74-
_patch_cache_method(cache, method_name)
119+
_patch_cache_method(cache, method_name, address, port)
75120
cache._sentry_patched = True
76121

77122

123+
def _get_address_port(settings):
124+
# type: (dict[str, Any]) -> tuple[Optional[str], Optional[int]]
125+
location = settings.get("LOCATION")
126+
127+
# TODO: location can also be an array of locations
128+
# see: https://docs.djangoproject.com/en/5.0/topics/cache/#redis
129+
# GitHub issue: https://github.com/getsentry/sentry-python/issues/3062
130+
if not isinstance(location, str):
131+
return None, None
132+
133+
if "://" in location:
134+
parsed_url = urlparse(location)
135+
# remove the username and password from URL to not leak sensitive data.
136+
address = "{}://{}{}".format(
137+
parsed_url.scheme or "",
138+
parsed_url.hostname or "",
139+
parsed_url.path or "",
140+
)
141+
port = parsed_url.port
142+
else:
143+
address = location
144+
port = None
145+
146+
return address, int(port) if port is not None else None
147+
148+
78149
def patch_caching():
79150
# type: () -> None
80151
from sentry_sdk.integrations.django import DjangoIntegration
@@ -90,7 +161,13 @@ def sentry_get_item(self, alias):
90161

91162
integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
92163
if integration is not None and integration.cache_spans:
93-
_patch_cache(cache)
164+
from django.conf import settings
165+
166+
address, port = _get_address_port(
167+
settings.CACHES[alias or "default"]
168+
)
169+
170+
_patch_cache(cache, address, port)
94171

95172
return cache
96173

@@ -107,7 +184,9 @@ def sentry_create_connection(self, alias):
107184

108185
integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
109186
if integration is not None and integration.cache_spans:
110-
_patch_cache(cache)
187+
address, port = _get_address_port(self.settings[alias or "default"])
188+
189+
_patch_cache(cache, address, port)
111190

112191
return cache
113192

sentry_sdk/integrations/redis/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,8 @@ class RedisIntegration(Integration):
358358
def __init__(self, max_data_size=_DEFAULT_MAX_DATA_SIZE):
359359
# type: (int) -> None
360360
self.max_data_size = max_data_size
361+
# TODO: add some prefix that users can set to specify a cache key
362+
# GitHub issue: https://github.com/getsentry/sentry-python/issues/2965
361363

362364
@staticmethod
363365
def setup_once():

tests/integrations/aiohttp/test_aiohttp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ async def hello(request):
287287
async def test_traces_sampler_gets_request_object_in_sampling_context(
288288
sentry_init,
289289
aiohttp_client,
290-
DictionaryContaining, # noqa:N803
290+
DictionaryContaining, # noqa: N803
291291
ObjectDescribedBy, # noqa: N803
292292
):
293293
traces_sampler = mock.Mock()

tests/integrations/aws_lambda/test_aws.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,7 @@ def test_handler(event, context):
554554

555555
def test_traces_sampler_gets_correct_values_in_sampling_context(
556556
run_lambda_function,
557-
DictionaryContaining, # noqa:N803
557+
DictionaryContaining, # noqa: N803
558558
ObjectDescribedBy, # noqa: N803
559559
StringContaining, # noqa: N803
560560
):

0 commit comments

Comments
 (0)