diff --git a/ddtrace/_trace/span.py b/ddtrace/_trace/span.py index 6b79a6762f9..4ca5cb34a27 100644 --- a/ddtrace/_trace/span.py +++ b/ddtrace/_trace/span.py @@ -550,6 +550,51 @@ def set_exc_info( core.dispatch("span.exception", (self, exc_type, exc_val, exc_tb)) + def record_exception( + self, + exception: BaseException, + attributes: Optional[Dict[str, _JSONType]] = None, + timestamp: Optional[int] = None, + escaped=False, + ) -> None: + """ + Records an exception as span event. + If the exception is uncaught, :obj:`escaped` should be set :obj:`True`. It + will tag the span with an error tuple. + + :param Exception exception: the exception to record + :param dict attributes: optional attributes to add to the span event. It will override + the base attributes if :obj:`attributes` contains existing keys. + :param int timestamp: the timestamp of the span event. Will be set to now() if timestamp is :obj:`None`. + :param bool escaped: sets to :obj:`False` for a handled exception and :obj:`True` for a uncaught exception. + """ + if timestamp is None: + timestamp = time_ns() + + exc_type, exc_val, exc_tb = type(exception), exception, exception.__traceback__ + + if escaped: + self.set_exc_info(exc_type, exc_val, exc_tb) + + # get the traceback + buff = StringIO() + traceback.print_exception(exc_type, exc_val, exc_tb, file=buff, limit=config._span_traceback_max_size) + tb = buff.getvalue() + + # Set exception attributes in a manner that is consistent with the opentelemetry sdk + # https://github.com/open-telemetry/opentelemetry-python/blob/v1.24.0/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py#L998 + attrs = { + "exception.type": "%s.%s" % (exception.__class__.__module__, exception.__class__.__name__), + "exception.message": str(exception), + "exception.escaped": escaped, + "exception.stacktrace": tb, + } + if attributes: + # User provided attributes must take precedence over attrs + attrs.update(attributes) + + self._add_event(name="recorded exception", attributes=attrs, timestamp=timestamp) + def _pprint(self) -> str: """Return a human readable version of the span.""" data = [ diff --git a/releasenotes/notes/feat-add-dd-record-exception-033fd0436dfd2723.yaml b/releasenotes/notes/feat-add-dd-record-exception-033fd0436dfd2723.yaml new file mode 100644 index 00000000000..0808b23b71e --- /dev/null +++ b/releasenotes/notes/feat-add-dd-record-exception-033fd0436dfd2723.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + tracing: Introduces a record_exception method that adds an exception to a Span as a span event. + Refer to [Span.record_exception](https://ddtrace.readthedocs.io/en/stable/api.html#ddtrace.trace.Span.record_exception) + for more details. diff --git a/tests/tracer/test_span.py b/tests/tracer/test_span.py index 64fdeed4ffb..ab58e38e70f 100644 --- a/tests/tracer/test_span.py +++ b/tests/tracer/test_span.py @@ -533,6 +533,61 @@ def test_span_pointers(self): }, ] + def test_span_record_exception(self): + span = self.start_span("span") + try: + raise RuntimeError("bim") + except RuntimeError as e: + span.record_exception(e) + span.finish() + + span.assert_span_event_count(1) + span.assert_span_event_attributes( + 0, {"exception.type": "builtins.RuntimeError", "exception.message": "bim", "exception.escaped": False} + ) + + def test_span_record_multiple_exceptions(self): + span = self.start_span("span") + try: + raise RuntimeError("bim") + except RuntimeError as e: + span.record_exception(e) + + try: + raise RuntimeError("bam") + except RuntimeError as e: + span.record_exception(e) + span.finish() + + span.assert_span_event_count(2) + span.assert_span_event_attributes( + 0, {"exception.type": "builtins.RuntimeError", "exception.message": "bim", "exception.escaped": False} + ) + span.assert_span_event_attributes( + 1, {"exception.type": "builtins.RuntimeError", "exception.message": "bam", "exception.escaped": False} + ) + + def test_span_record_escaped_exception(self): + exc = RuntimeError("bim") + span = self.start_span("span") + try: + raise exc + except RuntimeError as e: + span.record_exception(e, escaped=True) + span.finish() + + span.assert_matches( + error=1, + meta={ + "error.message": str(exc), + "error.type": "%s.%s" % (exc.__class__.__module__, exc.__class__.__name__), + }, + ) + span.assert_span_event_count(1) + span.assert_span_event_attributes( + 0, {"exception.type": "builtins.RuntimeError", "exception.message": "bim", "exception.escaped": True} + ) + @pytest.mark.parametrize( "value,assertion", diff --git a/tests/utils.py b/tests/utils.py index 38ef5652814..851fde48e9f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -848,6 +848,29 @@ def assert_metrics(self, metrics, exact=False): self, key, self._metrics[key], value ) + def assert_span_event_count(self, count): + """Assert this span has the expected number of span_events""" + assert len(self._events) == count, "Span count {0} != {1}".format(len(self._events), count) + + def assert_span_event_attributes(self, event_idx, attrs): + """ + Assertion method to ensure this span's span event match as expected + + Example:: + + span = TestSpan(span) + span.assert_span_event(0, {"exception.type": "builtins.RuntimeError"}) + + :param event_idx: id of the span event + :type event_idx: integer + """ + span_event_attrs = self._events[event_idx].attributes + for name, value in attrs.items(): + assert name in span_event_attrs, "{0!r} does not have property {1!r}".format(span_event_attrs, name) + assert span_event_attrs[name] == value, "{0!r} property {1}: {2!r} != {3!r}".format( + span_event_attrs, name, span_event_attrs[name], value + ) + class TracerSpanContainer(TestSpanContainer): """