Skip to content

Commit 952ad60

Browse files
fix: use negative limit for traceback.print_exception() [backport 3.2] (#12768)
Backport 942c3ef from #12705 to 3.2. 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) Co-authored-by: Federico Mon <[email protected]>
1 parent 2dc268f commit 952ad60

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
@@ -15,6 +15,7 @@
1515
from typing import cast
1616

1717
from ddtrace import config
18+
from ddtrace._trace._limits import MAX_SPAN_META_VALUE_LEN
1819
from ddtrace._trace._span_link import SpanLink
1920
from ddtrace._trace._span_link import SpanLinkKind
2021
from ddtrace._trace._span_pointer import _SpanPointer
@@ -497,19 +498,68 @@ def set_traceback(self, limit: Optional[int] = None):
497498
"""If the current stack has an exception, tag the span with the
498499
relevant error info. If not, tag it with the current python stack.
499500
"""
500-
if limit is None:
501-
limit = config._span_traceback_max_size
502-
503501
(exc_type, exc_val, exc_tb) = sys.exc_info()
504502

505503
if exc_type and exc_val and exc_tb:
506-
self.set_exc_info(exc_type, exc_val, exc_tb)
504+
if limit:
505+
limit = -abs(limit)
506+
self.set_exc_info(exc_type, exc_val, exc_tb, limit=limit)
507507
else:
508+
if limit is None:
509+
limit = config._span_traceback_max_size
508510
tb = "".join(traceback.format_stack(limit=limit + 1)[:-1])
509511
self._meta[ERROR_STACK] = tb
510512

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

525575
self.error = 1
526576

527-
# get the traceback
528-
buff = StringIO()
529-
traceback.print_exception(exc_type, exc_val, exc_tb, file=buff, limit=config._span_traceback_max_size)
530-
tb = buff.getvalue()
577+
tb = self._get_traceback(exc_type, exc_val, exc_tb, limit=limit)
531578

532579
# readable version of type (e.g. exceptions.ZeroDivisionError)
533580
exc_type_str = "%s.%s" % (exc_type.__module__, exc_type.__name__)
@@ -576,10 +623,7 @@ def record_exception(
576623
if escaped:
577624
self.set_exc_info(exc_type, exc_val, exc_tb)
578625

579-
# get the traceback
580-
buff = StringIO()
581-
traceback.print_exception(exc_type, exc_val, exc_tb, file=buff, limit=config._span_traceback_max_size)
582-
tb = buff.getvalue()
626+
tb = self._get_traceback(exc_type, exc_val, exc_tb)
583627

584628
# Set exception attributes in a manner that is consistent with the opentelemetry sdk
585629
# 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)