Skip to content

Commit f6325f7

Browse files
Add query source to DB spans (#2521)
Adding OTel compatible information to database spans that show the code location of the query. Refs getsentry/team-sdks#40 --------- Co-authored-by: Ivana Kellyerova <[email protected]>
1 parent a51132e commit f6325f7

File tree

8 files changed

+449
-3
lines changed

8 files changed

+449
-3
lines changed

sentry_sdk/consts.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,30 @@ class SPANDATA:
164164
Example: 16456
165165
"""
166166

167+
CODE_FILEPATH = "code.filepath"
168+
"""
169+
The source code file name that identifies the code unit as uniquely as possible (preferably an absolute file path).
170+
Example: "/app/myapplication/http/handler/server.py"
171+
"""
172+
173+
CODE_LINENO = "code.lineno"
174+
"""
175+
The line number in `code.filepath` best representing the operation. It SHOULD point within the code unit named in `code.function`.
176+
Example: 42
177+
"""
178+
179+
CODE_FUNCTION = "code.function"
180+
"""
181+
The method or function name, or equivalent (usually rightmost part of the code unit's name).
182+
Example: "server_request"
183+
"""
184+
185+
CODE_NAMESPACE = "code.namespace"
186+
"""
187+
The "namespace" within which `code.function` is defined. Usually the qualified class or module name, such that `code.namespace` + some separator + `code.function` form a unique identifier for the code unit.
188+
Example: "http.handler"
189+
"""
190+
167191

168192
class OP:
169193
CACHE_GET_ITEM = "cache.get_item"
@@ -264,6 +288,8 @@ def __init__(
264288
max_value_length=DEFAULT_MAX_VALUE_LENGTH, # type: int
265289
enable_backpressure_handling=True, # type: bool
266290
error_sampler=None, # type: Optional[Callable[[Event, Hint], Union[float, bool]]]
291+
enable_db_query_source=False, # type: bool
292+
db_query_source_threshold_ms=100, # type: int
267293
spotlight=None, # type: Optional[Union[bool, str]]
268294
):
269295
# type: (...) -> None

sentry_sdk/tracing.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,8 @@ def finish(self, hub=None, end_timestamp=None):
479479
self.timestamp = datetime_utcnow()
480480

481481
maybe_create_breadcrumbs_from_span(hub, self)
482+
add_additional_span_data(hub, self)
483+
482484
return None
483485

484486
def to_json(self):
@@ -998,6 +1000,7 @@ async def my_async_function():
9981000
from sentry_sdk.tracing_utils import (
9991001
Baggage,
10001002
EnvironHeaders,
1003+
add_additional_span_data,
10011004
extract_sentrytrace_data,
10021005
has_tracing_enabled,
10031006
maybe_create_breadcrumbs_from_span,

sentry_sdk/tracing_utils.py

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import re
21
import contextlib
2+
import re
3+
import sys
34

45
import sentry_sdk
5-
from sentry_sdk.consts import OP
6+
from sentry_sdk.consts import OP, SPANDATA
67
from sentry_sdk.utils import (
78
capture_internal_exceptions,
89
Dsn,
910
match_regex_list,
1011
to_string,
1112
is_sentry_url,
13+
_is_external_source,
1214
)
1315
from sentry_sdk._compat import PY2, iteritems
1416
from sentry_sdk._types import TYPE_CHECKING
@@ -29,6 +31,8 @@
2931
from typing import Optional
3032
from typing import Union
3133

34+
from types import FrameType
35+
3236

3337
SENTRY_TRACE_REGEX = re.compile(
3438
"^[ \t]*" # whitespace
@@ -162,6 +166,98 @@ def maybe_create_breadcrumbs_from_span(hub, span):
162166
)
163167

164168

169+
def add_query_source(hub, span):
170+
# type: (sentry_sdk.Hub, sentry_sdk.tracing.Span) -> None
171+
"""
172+
Adds OTel compatible source code information to the span
173+
"""
174+
client = hub.client
175+
if client is None:
176+
return
177+
178+
if span.timestamp is None or span.start_timestamp is None:
179+
return
180+
181+
should_add_query_source = client.options.get("enable_db_query_source", False)
182+
if not should_add_query_source:
183+
return
184+
185+
duration = span.timestamp - span.start_timestamp
186+
threshold = client.options.get("db_query_source_threshold_ms", 0)
187+
slow_query = duration.microseconds > threshold * 1000
188+
189+
if not slow_query:
190+
return
191+
192+
project_root = client.options["project_root"]
193+
194+
# Find the correct frame
195+
frame = sys._getframe() # type: Union[FrameType, None]
196+
while frame is not None:
197+
try:
198+
abs_path = frame.f_code.co_filename
199+
except Exception:
200+
abs_path = ""
201+
202+
try:
203+
namespace = frame.f_globals.get("__name__")
204+
except Exception:
205+
namespace = None
206+
207+
is_sentry_sdk_frame = namespace is not None and namespace.startswith(
208+
"sentry_sdk."
209+
)
210+
if (
211+
abs_path.startswith(project_root)
212+
and not _is_external_source(abs_path)
213+
and not is_sentry_sdk_frame
214+
):
215+
break
216+
frame = frame.f_back
217+
else:
218+
frame = None
219+
220+
# Set the data
221+
if frame is not None:
222+
try:
223+
lineno = frame.f_lineno
224+
except Exception:
225+
lineno = None
226+
if lineno is not None:
227+
span.set_data(SPANDATA.CODE_LINENO, frame.f_lineno)
228+
229+
try:
230+
namespace = frame.f_globals.get("__name__")
231+
except Exception:
232+
namespace = None
233+
if namespace is not None:
234+
span.set_data(SPANDATA.CODE_NAMESPACE, namespace)
235+
236+
try:
237+
filepath = frame.f_code.co_filename
238+
except Exception:
239+
filepath = None
240+
if filepath is not None:
241+
span.set_data(SPANDATA.CODE_FILEPATH, frame.f_code.co_filename)
242+
243+
try:
244+
code_function = frame.f_code.co_name
245+
except Exception:
246+
code_function = None
247+
248+
if code_function is not None:
249+
span.set_data(SPANDATA.CODE_FUNCTION, frame.f_code.co_name)
250+
251+
252+
def add_additional_span_data(hub, span):
253+
# type: (sentry_sdk.Hub, sentry_sdk.tracing.Span) -> None
254+
"""
255+
Adds additional data to the span
256+
"""
257+
if span.op == OP.DB:
258+
add_query_source(hub, span)
259+
260+
165261
def extract_sentrytrace_data(header):
166262
# type: (Optional[str]) -> Optional[Dict[str, Union[str, bool, None]]]
167263
"""

tests/integrations/asyncpg/test_asyncpg.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@
2727

2828
from asyncpg import connect, Connection
2929

30-
from sentry_sdk import capture_message
30+
from sentry_sdk import capture_message, start_transaction
3131
from sentry_sdk.integrations.asyncpg import AsyncPGIntegration
32+
from sentry_sdk.consts import SPANDATA
3233

3334

3435
PG_CONNECTION_URI = "postgresql://{}:{}@{}/{}".format(
@@ -460,3 +461,85 @@ async def test_connection_pool(sentry_init, capture_events) -> None:
460461
"type": "default",
461462
},
462463
]
464+
465+
466+
@pytest.mark.asyncio
467+
@pytest.mark.parametrize("enable_db_query_source", [None, False])
468+
async def test_query_source_disabled(
469+
sentry_init, capture_events, enable_db_query_source
470+
):
471+
sentry_options = {
472+
"integrations": [AsyncPGIntegration()],
473+
"enable_tracing": True,
474+
}
475+
if enable_db_query_source is not None:
476+
sentry_options["enable_db_query_source"] = enable_db_query_source
477+
sentry_options["db_query_source_threshold_ms"] = 0
478+
479+
sentry_init(**sentry_options)
480+
481+
events = capture_events()
482+
483+
with start_transaction(name="test_transaction", sampled=True):
484+
conn: Connection = await connect(PG_CONNECTION_URI)
485+
486+
await conn.execute(
487+
"INSERT INTO users(name, password, dob) VALUES ('Alice', 'secret', '1990-12-25')",
488+
)
489+
490+
await conn.close()
491+
492+
(event,) = events
493+
494+
span = event["spans"][-1]
495+
assert span["description"].startswith("INSERT INTO")
496+
497+
data = span.get("data", {})
498+
499+
assert SPANDATA.CODE_LINENO not in data
500+
assert SPANDATA.CODE_NAMESPACE not in data
501+
assert SPANDATA.CODE_FILEPATH not in data
502+
assert SPANDATA.CODE_FUNCTION not in data
503+
504+
505+
@pytest.mark.asyncio
506+
async def test_query_source(sentry_init, capture_events):
507+
sentry_init(
508+
integrations=[AsyncPGIntegration()],
509+
enable_tracing=True,
510+
enable_db_query_source=True,
511+
db_query_source_threshold_ms=0,
512+
)
513+
514+
events = capture_events()
515+
516+
with start_transaction(name="test_transaction", sampled=True):
517+
conn: Connection = await connect(PG_CONNECTION_URI)
518+
519+
await conn.execute(
520+
"INSERT INTO users(name, password, dob) VALUES ('Alice', 'secret', '1990-12-25')",
521+
)
522+
523+
await conn.close()
524+
525+
(event,) = events
526+
527+
span = event["spans"][-1]
528+
assert span["description"].startswith("INSERT INTO")
529+
530+
data = span.get("data", {})
531+
532+
assert SPANDATA.CODE_LINENO in data
533+
assert SPANDATA.CODE_NAMESPACE in data
534+
assert SPANDATA.CODE_FILEPATH in data
535+
assert SPANDATA.CODE_FUNCTION in data
536+
537+
assert type(data.get(SPANDATA.CODE_LINENO)) == int
538+
assert data.get(SPANDATA.CODE_LINENO) > 0
539+
assert (
540+
data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.asyncpg.test_asyncpg"
541+
)
542+
assert data.get(SPANDATA.CODE_FILEPATH).endswith(
543+
"tests/integrations/asyncpg/test_asyncpg.py"
544+
)
545+
assert data.get(SPANDATA.CODE_FUNCTION) == "test_query_source"

tests/integrations/django/myapp/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def path(path, *args, **kwargs):
5757
path("template-test2", views.template_test2, name="template_test2"),
5858
path("template-test3", views.template_test3, name="template_test3"),
5959
path("postgres-select", views.postgres_select, name="postgres_select"),
60+
path("postgres-select-slow", views.postgres_select_orm, name="postgres_select_orm"),
6061
path(
6162
"permission-denied-exc",
6263
views.permission_denied_exc,

tests/integrations/django/myapp/views.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,12 @@ def postgres_select(request, *args, **kwargs):
193193
return HttpResponse("ok")
194194

195195

196+
@csrf_exempt
197+
def postgres_select_orm(request, *args, **kwargs):
198+
user = User.objects.using("postgres").all().first()
199+
return HttpResponse("ok {}".format(user))
200+
201+
196202
@csrf_exempt
197203
def permission_denied_exc(*args, **kwargs):
198204
raise PermissionDenied("bye")

0 commit comments

Comments
 (0)