-
Notifications
You must be signed in to change notification settings - Fork 643
feat: Avoid truncating span descriptions #782
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,29 +6,37 @@ | |
| AnnotatedValue, | ||
| capture_internal_exception, | ||
| disable_capture_event, | ||
| format_timestamp, | ||
| json_dumps, | ||
| safe_repr, | ||
| strip_string, | ||
| format_timestamp, | ||
| ) | ||
|
|
||
| import sentry_sdk.utils | ||
|
|
||
| from sentry_sdk._compat import text_type, PY2, string_types, number_types, iteritems | ||
|
|
||
| from sentry_sdk._types import MYPY | ||
|
|
||
| if MYPY: | ||
| from datetime import timedelta | ||
|
|
||
| from types import TracebackType | ||
|
|
||
| from typing import Any | ||
| from typing import Callable | ||
| from typing import ContextManager | ||
| from typing import Dict | ||
| from typing import List | ||
| from typing import Optional | ||
| from typing import Callable | ||
| from typing import Union | ||
| from typing import ContextManager | ||
| from typing import Tuple | ||
| from typing import Type | ||
| from typing import Union | ||
|
|
||
| from sentry_sdk._types import NotImplementedType, Event | ||
|
|
||
| Span = Dict[str, Any] | ||
|
|
||
| ReprProcessor = Callable[[Any, Dict[str, Any]], Union[NotImplementedType, str]] | ||
| Segment = Union[str, int] | ||
|
|
||
|
|
@@ -48,6 +56,17 @@ | |
| # Bytes are technically not strings in Python 3, but we can serialize them | ||
| serializable_str_types = (str, bytes) | ||
|
|
||
|
|
||
| # Maximum length of JSON-serialized event payloads that can be safely sent | ||
| # before the server may reject the event due to its size. This is not intended | ||
| # to reflect actual values defined server-side, but rather only be an upper | ||
| # bound for events sent by the SDK. | ||
| # | ||
| # Can be overwritten if wanting to send more bytes, e.g. with a custom server. | ||
| # When changing this, keep in mind that events may be a little bit larger than | ||
| # this value due to attached metadata, so keep the number conservative. | ||
| MAX_EVENT_BYTES = 10 ** 6 | ||
|
|
||
| MAX_DATABAG_DEPTH = 5 | ||
| MAX_DATABAG_BREADTH = 10 | ||
| CYCLE_MARKER = u"<cyclic>" | ||
|
|
@@ -93,11 +112,12 @@ def __exit__( | |
| self._ids.pop(id(self._objs.pop()), None) | ||
|
|
||
|
|
||
| def serialize(event, **kwargs): | ||
| # type: (Event, **Any) -> Event | ||
| def serialize(event, smart_transaction_trimming=False, **kwargs): | ||
| # type: (Event, bool, **Any) -> Event | ||
| memo = Memo() | ||
| path = [] # type: List[Segment] | ||
| meta_stack = [] # type: List[Dict[str, Any]] | ||
| span_description_bytes = [] # type: List[int] | ||
|
|
||
| def _annotate(**meta): | ||
| # type: (**Any) -> None | ||
|
|
@@ -323,14 +343,113 @@ def _serialize_node_impl( | |
| if not isinstance(obj, string_types): | ||
| obj = safe_repr(obj) | ||
|
|
||
| # Allow span descriptions to be longer than other strings. | ||
| # | ||
| # 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 | ||
| # later in _truncate_span_descriptions. | ||
| if ( | ||
| smart_transaction_trimming | ||
| and len(path) == 3 | ||
| and path[0] == "spans" | ||
| and path[-1] == "description" | ||
| ): | ||
| span_description_bytes.append(len(obj)) | ||
| return obj | ||
| return _flatten_annotated(strip_string(obj)) | ||
|
|
||
| def _truncate_span_descriptions(serialized_event, event, excess_bytes): | ||
| # type: (Event, Event, int) -> None | ||
| """ | ||
| Modifies serialized_event in-place trying to remove excess_bytes from | ||
| span descriptions. The original event is used read-only to access the | ||
| span timestamps (represented as RFC3399-formatted strings in | ||
| serialized_event). | ||
|
|
||
| It uses heuristics to prioritize preserving the description of spans | ||
| that might be the most interesting ones in terms of understanding and | ||
| optimizing performance. | ||
| """ | ||
| # When truncating a description, preserve a small prefix. | ||
| min_length = 10 | ||
|
|
||
| def shortest_duration_longest_description_first(args): | ||
| # type: (Tuple[int, Span]) -> Tuple[timedelta, int] | ||
| i, serialized_span = args | ||
| span = event["spans"][i] | ||
| now = datetime.utcnow() | ||
| start = span.get("start_timestamp") or now | ||
| end = span.get("timestamp") or now | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is I assume if we get a span without a valid start_timestamp or timestamp we should disregard it right?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is there for the sake of defensive coding. Is it not the role of the serializer to validate spans. Note that
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For sure, I see that. Maybe this is out of scope for the PR, but I wonder if we can make the guarantee that the spans are valid before hit hits the serializer. That way we don't need the extra func call. Also, if
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The original start and end timestamps could also give a negative duration. That will only affect the sort order. |
||
| duration = end - start | ||
| description = serialized_span.get("description") or "" | ||
| return (duration, -len(description)) | ||
|
|
||
| # Note: for simplicity we sort spans by exact duration and description | ||
| # length. If ever needed, we could have a more involved heuristic, e.g. | ||
| # replacing exact durations with "buckets" and/or looking at other span | ||
| # properties. | ||
| path.append("spans") | ||
| for i, span in sorted( | ||
| enumerate(serialized_event.get("spans") or []), | ||
| key=shortest_duration_longest_description_first, | ||
| ): | ||
| description = span.get("description") or "" | ||
| if len(description) <= min_length: | ||
| continue | ||
| excess_bytes -= len(description) - min_length | ||
| path.extend([i, "description"]) | ||
| # Note: the last time we call strip_string we could preserve a few | ||
| # more bytes up to a total length of MAX_EVENT_BYTES. Since that's | ||
| # not strictly required, we leave it out for now for simplicity. | ||
| span["description"] = _flatten_annotated( | ||
| strip_string(description, max_length=min_length) | ||
| ) | ||
| del path[-2:] | ||
| del meta_stack[len(path) + 1 :] | ||
|
|
||
| if excess_bytes <= 0: | ||
| break | ||
| path.pop() | ||
| del meta_stack[len(path) + 1 :] | ||
|
|
||
| disable_capture_event.set(True) | ||
| try: | ||
| rv = _serialize_node(event, **kwargs) | ||
| if meta_stack and isinstance(rv, dict): | ||
| rv["_meta"] = meta_stack[0] | ||
|
|
||
| sum_span_description_bytes = sum(span_description_bytes) | ||
| if smart_transaction_trimming and sum_span_description_bytes > 0: | ||
| span_count = len(event.get("spans") or []) | ||
| # This is an upper bound of how many bytes all descriptions would | ||
| # consume if the usual string truncation in _serialize_node_impl | ||
| # would have taken place, not accounting for the metadata attached | ||
| # as event["_meta"]. | ||
| descriptions_budget_bytes = span_count * sentry_sdk.utils.MAX_STRING_LENGTH | ||
|
|
||
| # If by not truncating descriptions we ended up with more bytes than | ||
| # per the usual string truncation, check if the event is too large | ||
| # and we need to truncate some descriptions. | ||
| # | ||
| # This is guarded with an if statement to avoid JSON-encoding the | ||
| # event unnecessarily. | ||
| if sum_span_description_bytes > descriptions_budget_bytes: | ||
| original_bytes = len(json_dumps(rv)) | ||
| excess_bytes = original_bytes - MAX_EVENT_BYTES | ||
| if excess_bytes > 0: | ||
| # Event is too large, will likely be discarded by the | ||
| # server. Trim it down before sending. | ||
| _truncate_span_descriptions(rv, event, excess_bytes) | ||
|
|
||
| # Span descriptions truncated, set or reset _meta. | ||
| # | ||
| # We run the same code earlier because we want to account | ||
| # for _meta when calculating original_bytes, the number of | ||
| # bytes in the JSON-encoded event. | ||
| if meta_stack and isinstance(rv, dict): | ||
| rv["_meta"] = meta_stack[0] | ||
| return rv | ||
| finally: | ||
| disable_capture_event.set(False) | ||
Uh oh!
There was an error while loading. Please reload this page.