Skip to content

Commit 009ecef

Browse files
authored
feat(tracer): deprecate escaped and timestamp attributes in record_exception API (#13798)
# Motivation `record_exception` call was added few releases ago because some customers were asking to be able to send manually errors. However, it was mainly based on the OpenTelemetry implementation. The OTEL sdk are inconsistent between tracers and some implementations are using for example `escaped` attribute which does not make sense at all for us. An [RFC](https://docs.google.com/document/d/1-7CsG8GZVnNU198FfzavCNMI6IF0cGzFfS8cIz6Pu1g/edit?usp=sharing) was written to ease the implementation across tracer. This PR is there to make the python implementation consistent with the RFC. # Changes - Deprecates `escaped` and `timestamp` attributes - Add a validation steps for attributes. If an attribute has not one of the typed defined in this [RFC](https://docs.google.com/document/d/1cVod_VI7Yruq8U9dfMRFJd7npDu-uBpste2IB04GyaQ/edit?tab=t.0#heading=h.z8rsw69ccced), the span event will be dropped. Without span validation, it would be "shadow" dropped which could make the user think the feature is not working. - Improves `record_exception` doc. ## 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 57bc9cd commit 009ecef

File tree

4 files changed

+116
-41
lines changed

4 files changed

+116
-41
lines changed

ddtrace/_trace/span.py

Lines changed: 77 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,16 @@
4747
from ddtrace.internal.compat import NumericType
4848
from ddtrace.internal.compat import ensure_text
4949
from ddtrace.internal.compat import is_integer
50+
from ddtrace.internal.constants import MAX_INT_64BITS as _MAX_INT_64BITS
5051
from ddtrace.internal.constants import MAX_UINT_64BITS as _MAX_UINT_64BITS
52+
from ddtrace.internal.constants import MIN_INT_64BITS as _MIN_INT_64BITS
5153
from ddtrace.internal.constants import SPAN_API_DATADOG
5254
from ddtrace.internal.logger import get_logger
5355
from ddtrace.internal.sampling import SamplingMechanism
5456
from ddtrace.internal.sampling import set_sampling_decision_maker
57+
from ddtrace.internal.utils.deprecations import DDTraceDeprecationWarning
5558
from ddtrace.settings._config import config
59+
from ddtrace.vendor.debtcollector import deprecate
5660

5761

5862
class SpanEvent:
@@ -604,42 +608,93 @@ def record_exception(
604608
exception: BaseException,
605609
attributes: Optional[Dict[str, _AttributeValueType]] = None,
606610
timestamp: Optional[int] = None,
607-
escaped=False,
611+
escaped: bool = False,
608612
) -> None:
609613
"""
610-
Records an exception as span event.
611-
If the exception is uncaught, :obj:`escaped` should be set :obj:`True`. It
612-
will tag the span with an error tuple.
613-
614-
:param Exception exception: the exception to record
615-
:param dict attributes: optional attributes to add to the span event. It will override
616-
the base attributes if :obj:`attributes` contains existing keys.
617-
:param int timestamp: the timestamp of the span event. Will be set to now() if timestamp is :obj:`None`.
618-
:param bool escaped: sets to :obj:`False` for a handled exception and :obj:`True` for a uncaught exception.
614+
Records an exception as a span event. Multiple exceptions can be recorded on a span.
615+
616+
:param exception: The exception to record.
617+
:param attributes: Optional dictionary of additional attributes to add to the exception event.
618+
These attributes will override the default exception attributes if they contain the same keys.
619+
Valid attribute values include (homogeneous array of) strings, booleans, integers, floats.
620+
:param timestamp: Deprecated.
621+
:param escaped: Deprecated.
619622
"""
620-
if timestamp is None:
621-
timestamp = time_ns()
622-
623-
exc_type, exc_val, exc_tb = type(exception), exception, exception.__traceback__
624-
625623
if escaped:
626-
self.set_exc_info(exc_type, exc_val, exc_tb)
624+
deprecate(
625+
prefix="The escaped argument is deprecated for record_exception",
626+
message="""If an exception exits the scope of the span, it will automatically be
627+
reported in the span tags.""",
628+
category=DDTraceDeprecationWarning,
629+
removal_version="4.0.0",
630+
)
631+
if timestamp is not None:
632+
deprecate(
633+
prefix="The timestamp argument is deprecated for record_exception",
634+
message="""The timestamp of the span event should correspond to the time when the
635+
error is recorded which is set automatically.""",
636+
category=DDTraceDeprecationWarning,
637+
removal_version="4.0.0",
638+
)
627639

628-
tb = self._get_traceback(exc_type, exc_val, exc_tb)
640+
tb = self._get_traceback(type(exception), exception, exception.__traceback__)
629641

630-
# Set exception attributes in a manner that is consistent with the opentelemetry sdk
631-
# https://github.com/open-telemetry/opentelemetry-python/blob/v1.24.0/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py#L998
632-
attrs = {
642+
attrs: Dict[str, _AttributeValueType] = {
633643
"exception.type": "%s.%s" % (exception.__class__.__module__, exception.__class__.__name__),
634644
"exception.message": str(exception),
635-
"exception.escaped": escaped,
636645
"exception.stacktrace": tb,
637646
}
638647
if attributes:
648+
attributes = {k: v for k, v in attributes.items() if self._validate_attribute(k, v)}
649+
639650
# User provided attributes must take precedence over attrs
640651
attrs.update(attributes)
641652

642-
self._add_event(name="exception", attributes=attrs, timestamp=timestamp)
653+
self._add_event(name="exception", attributes=attrs, timestamp=time_ns())
654+
655+
def _validate_attribute(self, key: str, value: object) -> bool:
656+
if isinstance(value, (str, bool, int, float)):
657+
return self._validate_scalar(key, value)
658+
659+
if not isinstance(value, list):
660+
log.warning("record_exception: Attribute %s must be a string, number, or boolean: %s.", key, value)
661+
return False
662+
663+
if len(value) == 0:
664+
return True
665+
666+
if not isinstance(value[0], (str, bool, int, float)):
667+
log.warning("record_exception: List values %s must be string, number, or boolean: %s.", key, value)
668+
return False
669+
670+
first_type = type(value[0])
671+
for val in value:
672+
if not isinstance(val, first_type) or not self._validate_scalar(key, val):
673+
log.warning("record_exception: Attribute %s array must be homogenous: %s.", key, value)
674+
return False
675+
return True
676+
677+
def _validate_scalar(self, key: str, value: Union[bool, str, int, float]) -> bool:
678+
if isinstance(value, (bool, str)):
679+
return True
680+
681+
if isinstance(value, int):
682+
if value < _MIN_INT_64BITS or value > _MAX_INT_64BITS:
683+
log.warning(
684+
"record_exception: Attribute %s must be within the range of a signed 64-bit integer: %s.",
685+
key,
686+
value,
687+
)
688+
return False
689+
return True
690+
691+
if isinstance(value, float):
692+
if not math.isfinite(value):
693+
log.warning("record_exception: Attribute %s must be a finite number: %s.", key, value)
694+
return False
695+
return True
696+
697+
return False
643698

644699
def _pprint(self) -> str:
645700
"""Return a human readable version of the span."""

ddtrace/internal/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
COMPONENT = "component"
4141
HIGHER_ORDER_TRACE_ID_BITS = "_dd.p.tid"
4242
MAX_UINT_64BITS = (1 << 64) - 1
43+
MIN_INT_64BITS = -(2**63)
44+
MAX_INT_64BITS = 2**63 - 1
4345
SPAN_LINKS_KEY = "_dd.span_links"
4446
SPAN_EVENTS_KEY = "events"
4547
SPAN_API_DATADOG = "datadog"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
deprecations:
3+
- |
4+
tracing: The ``escaped`` and ``timestamp`` arguments in the ``record_exception`` method are deprecated and will be removed in version 4.0.0.

tests/tracer/test_span.py

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -554,9 +554,7 @@ def test_span_record_exception(self):
554554
span.finish()
555555

556556
span.assert_span_event_count(1)
557-
span.assert_span_event_attributes(
558-
0, {"exception.type": "builtins.RuntimeError", "exception.message": "bim", "exception.escaped": False}
559-
)
557+
span.assert_span_event_attributes(0, {"exception.type": "builtins.RuntimeError", "exception.message": "bim"})
560558

561559
def test_span_record_multiple_exceptions(self):
562560
span = self.start_span("span")
@@ -572,33 +570,49 @@ def test_span_record_multiple_exceptions(self):
572570
span.finish()
573571

574572
span.assert_span_event_count(2)
573+
span.assert_span_event_attributes(0, {"exception.type": "builtins.RuntimeError", "exception.message": "bim"})
574+
span.assert_span_event_attributes(1, {"exception.type": "builtins.RuntimeError", "exception.message": "bam"})
575+
576+
def test_span_record_exception_with_attributes(self):
577+
span = self.start_span("span")
578+
try:
579+
raise RuntimeError("bim")
580+
except RuntimeError as e:
581+
span.record_exception(e, {"foo": "bar"})
582+
span.finish()
583+
584+
span.assert_span_event_count(1)
575585
span.assert_span_event_attributes(
576-
0, {"exception.type": "builtins.RuntimeError", "exception.message": "bim", "exception.escaped": False}
577-
)
578-
span.assert_span_event_attributes(
579-
1, {"exception.type": "builtins.RuntimeError", "exception.message": "bam", "exception.escaped": False}
586+
0, {"exception.type": "builtins.RuntimeError", "exception.message": "bim", "foo": "bar"}
580587
)
581588

582-
def test_span_record_escaped_exception(self):
583-
exc = RuntimeError("bim")
589+
@mock.patch("ddtrace._trace.span.log")
590+
def test_span_record_exception_with_invalid_attributes(self, span_log):
584591
span = self.start_span("span")
585592
try:
586-
raise exc
593+
raise RuntimeError("bim")
587594
except RuntimeError as e:
588-
span.record_exception(e, escaped=True)
595+
span.record_exception(
596+
e, {"foo": "bar", "toto": ["titi", 1], "bim": 2**100, "tata": [[1]], "tutu": {"a": "b"}}
597+
)
589598
span.finish()
590599

591-
span.assert_matches(
592-
error=1,
593-
meta={
594-
"error.message": str(exc),
595-
"error.type": "%s.%s" % (exc.__class__.__module__, exc.__class__.__name__),
596-
},
597-
)
598600
span.assert_span_event_count(1)
599601
span.assert_span_event_attributes(
600-
0, {"exception.type": "builtins.RuntimeError", "exception.message": "bim", "exception.escaped": True}
602+
0, {"exception.type": "builtins.RuntimeError", "exception.message": "bim", "foo": "bar"}
601603
)
604+
expected_calls = [
605+
mock.call("record_exception: Attribute %s must be a string, number, or boolean: %s.", "tutu", {"a": "b"}),
606+
mock.call("record_exception: Attribute %s array must be homogenous: %s.", "toto", ["titi", 1]),
607+
mock.call("record_exception: List values %s must be string, number, or boolean: %s.", "tata", [[1]]),
608+
mock.call(
609+
"record_exception: Attribute %s must be within the range of a signed 64-bit integer: %s.",
610+
"bim",
611+
2**100,
612+
),
613+
]
614+
span_log.warning.assert_has_calls(expected_calls, any_order=True)
615+
assert span_log.warning.call_count == 4
602616

603617

604618
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)