Repository: open-telemetry/opentelemetry-python

Issue:
`exception.type` should capture the fully qualified type name, not just the class name
https://github.com/open-telemetry/opentelemetry-python/blob/721beb8b530e7a830c1e27b70c2fb9af6465baf1/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py#L999

According to the [spec](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/attributes-registry/exception.md), it should contain

> The type of the exception (its fully-qualified class name, if applicable). 

It'd uniquely identify exceptions and will make it easier to understand where they are coming from.



Discussion:
The second example in the linked spec `OSError` looks like python , and that's exactly what the linked code is returning.
@xrmx the spec then contradicts itself and I don't mind fixing it :)
In python some exceptions are builtin you won't have a path like in java. For others maybe prefixing it with its `.__class__.__module__` if it's not `builtins`.
Seems like it would also be better to use [`__qualname__`](https://peps.python.org/pep-3155/) instead of `__name__` in the (likely uncommon) case of nested exceptions.

Resolving patch:
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py
--- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py
@@ -995,8 +995,15 @@ def record_exception(
                 type(exception), value=exception, tb=exception.__traceback__
             )
         )
+        module = type(exception).__module__
+        qualname = type(exception).__qualname__
+        exception_type = (
+            f"{module}.{qualname}"
+            if module and module != "builtins"
+            else qualname
+        )
         _attributes: MutableMapping[str, types.AttributeValue] = {
-            "exception.type": exception.__class__.__name__,
+            "exception.type": exception_type,
             "exception.message": str(exception),
             "exception.stacktrace": stacktrace,
             "exception.escaped": str(escaped),


Test patch:
diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py
--- a/opentelemetry-sdk/tests/trace/test_trace.py
+++ b/opentelemetry-sdk/tests/trace/test_trace.py
@@ -636,6 +636,10 @@ def test_events(self):
         self.assertEqual(span.events, tuple(events))
 
 
+class DummyError(Exception):
+    pass
+
+
 class TestSpan(unittest.TestCase):
     # pylint: disable=too-many-public-methods
 
@@ -1144,6 +1148,25 @@ def error_status_test(context):
             .start_as_current_span("root")
         )
 
+    def test_record_exception_fqn(self):
+        span = trace._Span("name", mock.Mock(spec=trace_api.SpanContext))
+        exception = DummyError("error")
+        exception_type = "tests.trace.test_trace.DummyError"
+        span.record_exception(exception)
+        exception_event = span.events[0]
+        self.assertEqual("exception", exception_event.name)
+        self.assertEqual(
+            "error", exception_event.attributes["exception.message"]
+        )
+        self.assertEqual(
+            exception_type,
+            exception_event.attributes["exception.type"],
+        )
+        self.assertIn(
+            "DummyError: error",
+            exception_event.attributes["exception.stacktrace"],
+        )
+
     def test_record_exception(self):
         span = trace._Span("name", mock.Mock(spec=trace_api.SpanContext))
         try: