Skip to content

Commit 44cc08e

Browse files
feat: Avoid truncating span descriptions (#782)
* feat: Avoid truncating span descriptions For database auto-instrumented spans, the description contains potentially long SQL queries that are most useful when not truncated. Because arbitrarily large events may be discarded by the server as a protection mechanism, we dynamically limit the description length, preserving the most important descriptions/queries. Performance impact Preliminary CPU profiling using [1] suggests that uuid4() dominates the execution time for code sending many transactions sequentially. Preliminary memory profiling using [2] and looking at the max RSS of a benchmark script suggests that the max RSS has no significant change (JSON encoding in CPython is implemented in C). In any case, we mitigate any increase in memory usage and run time for the majority of cases by avoiding any extra work when the total number of bytes consumed by descriptions do not exceed ~512 KB, which is equivalent to having the standard string truncation applied. Integrating profiling to the SDK is left for a future PR. [1]: https://pypi.org/project/zprofile/ [2]: /usr/bin/time -l (macOS) Co-authored-by: Markus Unterwaditzer <[email protected]>
1 parent 2e0e4fd commit 44cc08e

File tree

3 files changed

+221
-8
lines changed

3 files changed

+221
-8
lines changed

sentry_sdk/client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,12 @@ def _prepare_event(
197197
# Postprocess the event here so that annotated types do
198198
# generally not surface in before_send
199199
if event is not None:
200-
event = serialize(event)
200+
event = serialize(
201+
event,
202+
smart_transaction_trimming=self.options["_experiments"].get(
203+
"smart_transaction_trimming"
204+
),
205+
)
201206

202207
before_send = self.options["before_send"]
203208
if before_send is not None and event.get("type") != "transaction":

sentry_sdk/serializer.py

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,37 @@
66
AnnotatedValue,
77
capture_internal_exception,
88
disable_capture_event,
9+
format_timestamp,
10+
json_dumps,
911
safe_repr,
1012
strip_string,
11-
format_timestamp,
1213
)
1314

15+
import sentry_sdk.utils
16+
1417
from sentry_sdk._compat import text_type, PY2, string_types, number_types, iteritems
1518

1619
from sentry_sdk._types import MYPY
1720

1821
if MYPY:
22+
from datetime import timedelta
23+
1924
from types import TracebackType
2025

2126
from typing import Any
27+
from typing import Callable
28+
from typing import ContextManager
2229
from typing import Dict
2330
from typing import List
2431
from typing import Optional
25-
from typing import Callable
26-
from typing import Union
27-
from typing import ContextManager
32+
from typing import Tuple
2833
from typing import Type
34+
from typing import Union
2935

3036
from sentry_sdk._types import NotImplementedType, Event
3137

38+
Span = Dict[str, Any]
39+
3240
ReprProcessor = Callable[[Any, Dict[str, Any]], Union[NotImplementedType, str]]
3341
Segment = Union[str, int]
3442

@@ -48,6 +56,17 @@
4856
# Bytes are technically not strings in Python 3, but we can serialize them
4957
serializable_str_types = (str, bytes)
5058

59+
60+
# Maximum length of JSON-serialized event payloads that can be safely sent
61+
# before the server may reject the event due to its size. This is not intended
62+
# to reflect actual values defined server-side, but rather only be an upper
63+
# bound for events sent by the SDK.
64+
#
65+
# Can be overwritten if wanting to send more bytes, e.g. with a custom server.
66+
# When changing this, keep in mind that events may be a little bit larger than
67+
# this value due to attached metadata, so keep the number conservative.
68+
MAX_EVENT_BYTES = 10 ** 6
69+
5170
MAX_DATABAG_DEPTH = 5
5271
MAX_DATABAG_BREADTH = 10
5372
CYCLE_MARKER = u"<cyclic>"
@@ -93,11 +112,12 @@ def __exit__(
93112
self._ids.pop(id(self._objs.pop()), None)
94113

95114

96-
def serialize(event, **kwargs):
97-
# type: (Event, **Any) -> Event
115+
def serialize(event, smart_transaction_trimming=False, **kwargs):
116+
# type: (Event, bool, **Any) -> Event
98117
memo = Memo()
99118
path = [] # type: List[Segment]
100119
meta_stack = [] # type: List[Dict[str, Any]]
120+
span_description_bytes = [] # type: List[int]
101121

102122
def _annotate(**meta):
103123
# type: (**Any) -> None
@@ -325,14 +345,113 @@ def _serialize_node_impl(
325345
if not isinstance(obj, string_types):
326346
obj = safe_repr(obj)
327347

348+
# Allow span descriptions to be longer than other strings.
349+
#
350+
# For database auto-instrumented spans, the description contains
351+
# potentially long SQL queries that are most useful when not truncated.
352+
# Because arbitrarily large events may be discarded by the server as a
353+
# protection mechanism, we dynamically limit the description length
354+
# later in _truncate_span_descriptions.
355+
if (
356+
smart_transaction_trimming
357+
and len(path) == 3
358+
and path[0] == "spans"
359+
and path[-1] == "description"
360+
):
361+
span_description_bytes.append(len(obj))
362+
return obj
328363
return _flatten_annotated(strip_string(obj))
329364

365+
def _truncate_span_descriptions(serialized_event, event, excess_bytes):
366+
# type: (Event, Event, int) -> None
367+
"""
368+
Modifies serialized_event in-place trying to remove excess_bytes from
369+
span descriptions. The original event is used read-only to access the
370+
span timestamps (represented as RFC3399-formatted strings in
371+
serialized_event).
372+
373+
It uses heuristics to prioritize preserving the description of spans
374+
that might be the most interesting ones in terms of understanding and
375+
optimizing performance.
376+
"""
377+
# When truncating a description, preserve a small prefix.
378+
min_length = 10
379+
380+
def shortest_duration_longest_description_first(args):
381+
# type: (Tuple[int, Span]) -> Tuple[timedelta, int]
382+
i, serialized_span = args
383+
span = event["spans"][i]
384+
now = datetime.utcnow()
385+
start = span.get("start_timestamp") or now
386+
end = span.get("timestamp") or now
387+
duration = end - start
388+
description = serialized_span.get("description") or ""
389+
return (duration, -len(description))
390+
391+
# Note: for simplicity we sort spans by exact duration and description
392+
# length. If ever needed, we could have a more involved heuristic, e.g.
393+
# replacing exact durations with "buckets" and/or looking at other span
394+
# properties.
395+
path.append("spans")
396+
for i, span in sorted(
397+
enumerate(serialized_event.get("spans") or []),
398+
key=shortest_duration_longest_description_first,
399+
):
400+
description = span.get("description") or ""
401+
if len(description) <= min_length:
402+
continue
403+
excess_bytes -= len(description) - min_length
404+
path.extend([i, "description"])
405+
# Note: the last time we call strip_string we could preserve a few
406+
# more bytes up to a total length of MAX_EVENT_BYTES. Since that's
407+
# not strictly required, we leave it out for now for simplicity.
408+
span["description"] = _flatten_annotated(
409+
strip_string(description, max_length=min_length)
410+
)
411+
del path[-2:]
412+
del meta_stack[len(path) + 1 :]
413+
414+
if excess_bytes <= 0:
415+
break
416+
path.pop()
417+
del meta_stack[len(path) + 1 :]
418+
330419
disable_capture_event.set(True)
331420
try:
332421
rv = _serialize_node(event, **kwargs)
333422
if meta_stack and isinstance(rv, dict):
334423
rv["_meta"] = meta_stack[0]
335424

425+
sum_span_description_bytes = sum(span_description_bytes)
426+
if smart_transaction_trimming and sum_span_description_bytes > 0:
427+
span_count = len(event.get("spans") or [])
428+
# This is an upper bound of how many bytes all descriptions would
429+
# consume if the usual string truncation in _serialize_node_impl
430+
# would have taken place, not accounting for the metadata attached
431+
# as event["_meta"].
432+
descriptions_budget_bytes = span_count * sentry_sdk.utils.MAX_STRING_LENGTH
433+
434+
# If by not truncating descriptions we ended up with more bytes than
435+
# per the usual string truncation, check if the event is too large
436+
# and we need to truncate some descriptions.
437+
#
438+
# This is guarded with an if statement to avoid JSON-encoding the
439+
# event unnecessarily.
440+
if sum_span_description_bytes > descriptions_budget_bytes:
441+
original_bytes = len(json_dumps(rv))
442+
excess_bytes = original_bytes - MAX_EVENT_BYTES
443+
if excess_bytes > 0:
444+
# Event is too large, will likely be discarded by the
445+
# server. Trim it down before sending.
446+
_truncate_span_descriptions(rv, event, excess_bytes)
447+
448+
# Span descriptions truncated, set or reset _meta.
449+
#
450+
# We run the same code earlier because we want to account
451+
# for _meta when calculating original_bytes, the number of
452+
# bytes in the JSON-encoded event.
453+
if meta_stack and isinstance(rv, dict):
454+
rv["_meta"] = meta_stack[0]
336455
return rv
337456
finally:
338457
disable_capture_event.set(False)

tests/integrations/sqlalchemy/test_sqlalchemy.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
from sqlalchemy.ext.declarative import declarative_base
77
from sqlalchemy.orm import relationship, sessionmaker
88

9-
from sentry_sdk import capture_message, start_transaction
9+
from sentry_sdk import capture_message, start_transaction, configure_scope
1010
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
11+
from sentry_sdk.utils import json_dumps, MAX_STRING_LENGTH
12+
from sentry_sdk.serializer import MAX_EVENT_BYTES
1113

1214

1315
def test_orm_queries(sentry_init, capture_events):
@@ -133,3 +135,90 @@ class Address(Base):
133135
- op='db': description='RELEASE SAVEPOINT sa_savepoint_4'\
134136
"""
135137
)
138+
139+
140+
def test_long_sql_query_preserved(sentry_init, capture_events):
141+
sentry_init(
142+
traces_sample_rate=1,
143+
integrations=[SqlalchemyIntegration()],
144+
_experiments={"smart_transaction_trimming": True},
145+
)
146+
events = capture_events()
147+
148+
engine = create_engine("sqlite:///:memory:")
149+
with start_transaction(name="test"):
150+
with engine.connect() as con:
151+
con.execute(" UNION ".join("SELECT {}".format(i) for i in range(100)))
152+
153+
(event,) = events
154+
description = event["spans"][0]["description"]
155+
assert description.startswith("SELECT 0 UNION SELECT 1")
156+
assert description.endswith("SELECT 98 UNION SELECT 99")
157+
158+
159+
def test_too_large_event_truncated(sentry_init, capture_events):
160+
sentry_init(
161+
traces_sample_rate=1,
162+
integrations=[SqlalchemyIntegration()],
163+
_experiments={"smart_transaction_trimming": True},
164+
)
165+
events = capture_events()
166+
167+
long_str = "x" * (MAX_STRING_LENGTH + 10)
168+
169+
with configure_scope() as scope:
170+
171+
@scope.add_event_processor
172+
def processor(event, hint):
173+
event["message"] = long_str
174+
return event
175+
176+
engine = create_engine("sqlite:///:memory:")
177+
with start_transaction(name="test"):
178+
with engine.connect() as con:
179+
for _ in range(2000):
180+
con.execute(" UNION ".join("SELECT {}".format(i) for i in range(100)))
181+
182+
(event,) = events
183+
184+
# Because of attached metadata in the "_meta" key, we may send out a little
185+
# bit more than MAX_EVENT_BYTES.
186+
max_bytes = 1.2 * MAX_EVENT_BYTES
187+
assert len(json_dumps(event)) < max_bytes
188+
189+
# Some spans are discarded.
190+
assert len(event["spans"]) == 999
191+
192+
# Some spans have their descriptions truncated. Because the test always
193+
# generates the same amount of descriptions and truncation is deterministic,
194+
# the number here should never change across test runs.
195+
#
196+
# Which exact span descriptions are truncated depends on the span durations
197+
# of each SQL query and is non-deterministic.
198+
assert len(event["_meta"]["spans"]) == 536
199+
200+
for i, span in enumerate(event["spans"]):
201+
description = span["description"]
202+
203+
assert description.startswith("SELECT ")
204+
if str(i) in event["_meta"]["spans"]:
205+
# Description must have been truncated
206+
assert len(description) == 10
207+
assert description.endswith("...")
208+
else:
209+
# Description was not truncated, check for original length
210+
assert len(description) == 1583
211+
assert description.endswith("SELECT 98 UNION SELECT 99")
212+
213+
# Smoke check the meta info for one of the spans.
214+
assert next(iter(event["_meta"]["spans"].values())) == {
215+
"description": {"": {"len": 1583, "rem": [["!limit", "x", 7, 10]]}}
216+
}
217+
218+
# Smoke check that truncation of other fields has not changed.
219+
assert len(event["message"]) == MAX_STRING_LENGTH
220+
221+
# The _meta for other truncated fields should be there as well.
222+
assert event["_meta"]["message"] == {
223+
"": {"len": 522, "rem": [["!limit", "x", 509, 512]]}
224+
}

0 commit comments

Comments
 (0)