Skip to content

Commit 942c3ef

Browse files
authored
fix: use negative limit for traceback.print_exception() (#12705)
Since `traceback.print_exception()` prints the exception with the "most recent call last" format, while using a positive integer for the limit parameter, our `error.stack` tag will be trimmed on the most interesting part if trimming is needed. By switching to a negative number (introduced in Python 3.5), the traceback will be trimmed on the other side, see relevant docs: https://docs.python.org/3/library/traceback.html#traceback.print_tb Screenshot of the bug in action: ![screenshot_2025-03-12_at_12 47 46](https://github.com/user-attachments/assets/18d3dbbd-eb02-4289-9ac8-3f5a54d12161) ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)
1 parent a0e062b commit 942c3ef

File tree

3 files changed

+156
-18
lines changed

3 files changed

+156
-18
lines changed

ddtrace/_trace/span.py

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from typing import cast
1717

1818
from ddtrace import config
19+
from ddtrace._trace._limits import MAX_SPAN_META_VALUE_LEN
1920
from ddtrace._trace._span_link import SpanLink
2021
from ddtrace._trace._span_link import SpanLinkKind
2122
from ddtrace._trace._span_pointer import _SpanPointer
@@ -500,19 +501,68 @@ def set_traceback(self, limit: Optional[int] = None):
500501
"""If the current stack has an exception, tag the span with the
501502
relevant error info. If not, tag it with the current python stack.
502503
"""
503-
if limit is None:
504-
limit = config._span_traceback_max_size
505-
506504
(exc_type, exc_val, exc_tb) = sys.exc_info()
507505

508506
if exc_type and exc_val and exc_tb:
509-
self.set_exc_info(exc_type, exc_val, exc_tb)
507+
if limit:
508+
limit = -abs(limit)
509+
self.set_exc_info(exc_type, exc_val, exc_tb, limit=limit)
510510
else:
511+
if limit is None:
512+
limit = config._span_traceback_max_size
511513
tb = "".join(traceback.format_stack(limit=limit + 1)[:-1])
512514
self._meta[ERROR_STACK] = tb
513515

516+
def _get_traceback(
517+
self,
518+
exc_type: Type[BaseException],
519+
exc_val: BaseException,
520+
exc_tb: Optional[TracebackType],
521+
limit: Optional[int] = None,
522+
) -> str:
523+
"""
524+
Return a formatted traceback as a string.
525+
If the traceback is too long, it will be truncated to the limit parameter,
526+
but from the end of the traceback (keeping the most recent frames).
527+
528+
If the traceback surpasses the MAX_SPAN_META_VALUE_LEN limit, it will
529+
try to reduce the traceback size by half until it fits
530+
within this limit (limit for tag values).
531+
532+
:param exc_type: the exception type
533+
:param exc_val: the exception value
534+
:param exc_tb: the exception traceback
535+
:param limit: the maximum number of frames to keep
536+
:return: the formatted traceback as a string
537+
"""
538+
# If limit is None, use the default value from the configuration
539+
if limit is None:
540+
limit = config._span_traceback_max_size
541+
# Ensure the limit is negative for traceback.print_exception (to keep most recent frames)
542+
limit: int = -abs(limit) # type: ignore[no-redef]
543+
544+
# Create a buffer to hold the traceback
545+
buff = StringIO()
546+
# Print the exception traceback to the buffer with the specified limit
547+
traceback.print_exception(exc_type, exc_val, exc_tb, file=buff, limit=limit)
548+
tb = buff.getvalue()
549+
550+
# Check if the traceback exceeds the maximum allowed length
551+
while len(tb) > MAX_SPAN_META_VALUE_LEN and abs(limit) > 1:
552+
# Reduce the limit by half and print the traceback again
553+
limit //= 2
554+
buff = StringIO()
555+
traceback.print_exception(exc_type, exc_val, exc_tb, file=buff, limit=limit)
556+
tb = buff.getvalue()
557+
558+
return tb
559+
514560
def set_exc_info(
515-
self, exc_type: Type[BaseException], exc_val: BaseException, exc_tb: Optional[TracebackType]
561+
self,
562+
exc_type: Type[BaseException],
563+
exc_val: BaseException,
564+
exc_tb: Optional[TracebackType],
565+
limit: Optional[int] = None,
516566
) -> None:
517567
"""Tag the span with an error tuple as from `sys.exc_info()`."""
518568
if not (exc_type and exc_val and exc_tb):
@@ -527,10 +577,7 @@ def set_exc_info(
527577

528578
self.error = 1
529579

530-
# get the traceback
531-
buff = StringIO()
532-
traceback.print_exception(exc_type, exc_val, exc_tb, file=buff, limit=config._span_traceback_max_size)
533-
tb = buff.getvalue()
580+
tb = self._get_traceback(exc_type, exc_val, exc_tb, limit=limit)
534581

535582
# readable version of type (e.g. exceptions.ZeroDivisionError)
536583
exc_type_str = "%s.%s" % (exc_type.__module__, exc_type.__name__)
@@ -579,10 +626,7 @@ def record_exception(
579626
if escaped:
580627
self.set_exc_info(exc_type, exc_val, exc_tb)
581628

582-
# get the traceback
583-
buff = StringIO()
584-
traceback.print_exception(exc_type, exc_val, exc_tb, file=buff, limit=config._span_traceback_max_size)
585-
tb = buff.getvalue()
629+
tb = self._get_traceback(exc_type, exc_val, exc_tb)
586630

587631
# Set exception attributes in a manner that is consistent with the opentelemetry sdk
588632
# https://github.com/open-telemetry/opentelemetry-python/blob/v1.24.0/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py#L998
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
fixes:
3+
- |
4+
internal: Fixes an issue where trimming a traceback to attach it to the span could result in the loss of the most recent frames.

tests/tracer/test_span.py

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import re
44
import sys
55
import time
6+
import traceback
67
from unittest.case import SkipTest
78

89
import mock
@@ -19,6 +20,7 @@
1920
from ddtrace.constants import VERSION_KEY
2021
from ddtrace.ext import SpanTypes
2122
from ddtrace.internal import core
23+
from ddtrace.internal.compat import PYTHON_VERSION_INFO
2224
from ddtrace.trace import Span
2325
from tests.subprocesstest import run_in_subprocess
2426
from tests.utils import TracerTestCase
@@ -286,11 +288,21 @@ def wrapper():
286288
assert stack, "No error stack collected"
287289
# one header "Traceback (most recent call last):" and one footer "ZeroDivisionError: division by zero"
288290
header_and_footer_lines = 2
289-
# Python 3.13 adds extra lines to the traceback:
290-
# File dd-trace-py/tests/tracer/test_span.py", line 279, in test_custom_traceback_size_with_error
291-
# wrapper()
292-
# ~~~~~~~^^
293-
multiplier = 3 if "~~" in stack else 2
291+
multiplier = 2
292+
if PYTHON_VERSION_INFO >= (3, 13):
293+
# Python 3.13 adds extra lines to the traceback:
294+
# File dd-trace-py/tests/tracer/test_span.py", line 279, in test_custom_traceback_size_with_error
295+
# wrapper()
296+
# ~~~~~~~^^
297+
multiplier = 3
298+
elif PYTHON_VERSION_INFO >= (3, 11):
299+
# Python 3.11 adds one extra line to the traceback:
300+
# File dd-trace-py/tests/tracer/test_span.py", line 272, in divide_by_zero
301+
# 1 / 0
302+
# ~~^~~
303+
# ZeroDivisionError: division by zero
304+
header_and_footer_lines += 1
305+
294306
assert (
295307
len(stack.splitlines()) == tb_length_limit * multiplier + header_and_footer_lines
296308
), "stacktrace should contain two lines per entry"
@@ -954,3 +966,81 @@ def _(span, *exc_info):
954966
raise AssertionError("should have raised")
955967
finally:
956968
core.reset_listeners("span.exception")
969+
970+
971+
def test_get_traceback_exceeds_max_value_length():
972+
"""Test with a long traceback that should be truncated."""
973+
974+
def deep_error(n):
975+
if n > 0:
976+
deep_error(n - 1)
977+
else:
978+
raise RuntimeError("Deep recursion error")
979+
980+
exc_type, exc_val, exc_tb = None, None, None
981+
try:
982+
deep_error(100) # Create a large traceback
983+
except Exception as e:
984+
exc_type, exc_val, exc_tb = e.__class__, e, e.__traceback__
985+
986+
span = Span("test.span")
987+
with mock.patch("ddtrace._trace.span.MAX_SPAN_META_VALUE_LEN", 260):
988+
result = span._get_traceback(exc_type, exc_val, exc_tb, limit=100)
989+
assert "Deep recursion error" in result
990+
assert len(result) <= 260 # Should be truncated
991+
992+
993+
def test_get_traceback_exact_limit():
994+
"""Test a case where the traceback length is exactly at the limit."""
995+
996+
def deep_error(n):
997+
if n > 0:
998+
deep_error(n - 1)
999+
else:
1000+
raise RuntimeError("Deep recursion error")
1001+
1002+
exc_type, exc_val, exc_tb = None, None, None
1003+
try:
1004+
deep_error(100) # Create a large traceback
1005+
except Exception as e:
1006+
exc_type, exc_val, exc_tb = e.__class__, e, e.__traceback__
1007+
1008+
span = Span("test.span")
1009+
formatted_exception = traceback.format_exception(exc_type, exc_val, exc_tb)
1010+
formatted_exception = [s + "\n" for item in formatted_exception for s in item.split("\n") if s]
1011+
exc_len = len(formatted_exception)
1012+
result = span._get_traceback(exc_type, exc_val, exc_tb, limit=exc_len)
1013+
split_result = result.splitlines()
1014+
split_result = [s + "\n" for item in split_result for s in item.split("\n") if s]
1015+
1016+
if PYTHON_VERSION_INFO >= (3, 11):
1017+
exc_len -= 1 # From Python 3.11, adds an extra line to the traceback
1018+
1019+
assert len(split_result) == exc_len - 2 # Should be exactly the same length as the traceback
1020+
1021+
1022+
def test_get_traceback_honors_config_traceback_max_size():
1023+
class CustomConfig:
1024+
_span_traceback_max_size = 2 # Force a zero limit
1025+
1026+
def deep_error(n):
1027+
if n > 0:
1028+
deep_error(n - 1)
1029+
else:
1030+
raise RuntimeError("Deep recursion error")
1031+
1032+
exc_type, exc_val, exc_tb = None, None, None
1033+
try:
1034+
deep_error(100) # Create a large traceback
1035+
except Exception as e:
1036+
exc_type, exc_val, exc_tb = e.__class__, e, e.__traceback__
1037+
1038+
span = Span("test.span")
1039+
with mock.patch("ddtrace._trace.span.config", CustomConfig):
1040+
result = span._get_traceback(exc_type, exc_val, exc_tb)
1041+
1042+
assert isinstance(result, str)
1043+
split_result = result.splitlines()
1044+
split_result = [s + "\n" for item in split_result for s in item.split("\n") if s]
1045+
assert len(split_result) < 8 # Value is 5 for Python 3.10
1046+
assert len(result) < 410 # Value is 377 for Python 3.10

0 commit comments

Comments
 (0)