Repository: open-telemetry/opentelemetry-python

Issue:
Add links to span after creation via `Span.add_link()` method
In https://github.com/open-telemetry/opentelemetry-specification/pull/3678 it was decided that it must be possible to add links to spans after they have been created. However, the [`Span` class](https://github.com/open-telemetry/opentelemetry-python/blob/da48e0b131ff34ff382b7d1206f71b2e31929cab/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py#L712) currently does not provide such an `add_link()` method.

Please implement the `Span.add_link()` method as per updated specification. Thank you!

![image](https://github.com/open-telemetry/opentelemetry-python/assets/22151295/b49f81ba-77f1-492f-b194-76c5f38f5d92)



Discussion:
Hi, I would like to work on this feature request.

Resolving patch:
diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py
--- a/opentelemetry-api/src/opentelemetry/trace/__init__.py
+++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py
@@ -85,7 +85,6 @@
 from deprecated import deprecated
 
 from opentelemetry import context as context_api
-from opentelemetry.attributes import BoundedAttributes  # type: ignore
 from opentelemetry.context.context import Context
 from opentelemetry.environment_variables import OTEL_PYTHON_TRACER_PROVIDER
 from opentelemetry.trace.propagation import (
@@ -144,9 +143,7 @@ def __init__(
         attributes: types.Attributes = None,
     ) -> None:
         super().__init__(context)
-        self._attributes = BoundedAttributes(
-            attributes=attributes
-        )  # type: types.Attributes
+        self._attributes = attributes
 
     @property
     def attributes(self) -> types.Attributes:
diff --git a/opentelemetry-api/src/opentelemetry/trace/span.py b/opentelemetry-api/src/opentelemetry/trace/span.py
--- a/opentelemetry-api/src/opentelemetry/trace/span.py
+++ b/opentelemetry-api/src/opentelemetry/trace/span.py
@@ -3,6 +3,7 @@
 import re
 import types as python_types
 import typing
+import warnings
 
 from opentelemetry.trace.status import Status, StatusCode
 from opentelemetry.util import types
@@ -117,6 +118,26 @@ def add_event(
         timestamp if the `timestamp` argument is omitted.
         """
 
+    def add_link(  # pylint: disable=no-self-use
+        self,
+        context: "SpanContext",
+        attributes: types.Attributes = None,
+    ) -> None:
+        """Adds a `Link`.
+
+        Adds a single `Link` with the `SpanContext` of the span to link to and,
+        optionally, attributes passed as arguments. Implementations may ignore
+        calls with an invalid span context.
+
+        Note: It is preferred to add links at span creation, instead of calling
+        this method later since samplers can only consider information already
+        present during span creation.
+        """
+        warnings.warn(
+            "Span.add_link() not implemented and will be a no-op. "
+            "Use opentelemetry-sdk >= 1.23 to add links after span creation"
+        )
+
     @abc.abstractmethod
     def update_name(self, name: str) -> None:
         """Updates the `Span` name.
@@ -523,6 +544,13 @@ def add_event(
     ) -> None:
         pass
 
+    def add_link(
+        self,
+        context: "SpanContext",
+        attributes: types.Attributes = None,
+    ) -> None:
+        pass
+
     def update_name(self, name: str) -> None:
         pass
 
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
@@ -850,6 +850,30 @@ def add_event(
             )
         )
 
+    @_check_span_ended
+    def _add_link(self, link: trace_api.Link) -> None:
+        self._links.append(link)
+
+    def add_link(
+        self,
+        context: SpanContext,
+        attributes: types.Attributes = None,
+    ) -> None:
+        if context is None or not context.is_valid:
+            return
+
+        attributes = BoundedAttributes(
+            self._limits.max_link_attributes,
+            attributes,
+            max_value_len=self._limits.max_attribute_length,
+        )
+        self._add_link(
+            trace_api.Link(
+                context=context,
+                attributes=attributes,
+            )
+        )
+
     def _readable_span(self) -> ReadableSpan:
         return ReadableSpan(
             name=self._name,


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
@@ -900,6 +900,37 @@ def test_links(self):
             with self.assertRaises(TypeError):
                 root.links[1].attributes["name"] = "new_neighbour"
 
+    def test_add_link(self):
+        id_generator = RandomIdGenerator()
+        other_context = trace_api.SpanContext(
+            trace_id=id_generator.generate_trace_id(),
+            span_id=id_generator.generate_span_id(),
+            is_remote=False,
+        )
+
+        with self.tracer.start_as_current_span("root") as root:
+            root.add_link(other_context, {"name": "neighbor"})
+
+            self.assertEqual(len(root.links), 1)
+            self.assertEqual(
+                root.links[0].context.trace_id, other_context.trace_id
+            )
+            self.assertEqual(
+                root.links[0].context.span_id, other_context.span_id
+            )
+            self.assertEqual(root.links[0].attributes, {"name": "neighbor"})
+
+            with self.assertRaises(TypeError):
+                root.links[0].attributes["name"] = "new_neighbour"
+
+    def test_add_link_with_invalid_span_context(self):
+        other_context = trace_api.INVALID_SPAN_CONTEXT
+
+        with self.tracer.start_as_current_span("root") as root:
+            root.add_link(other_context)
+
+            self.assertEqual(len(root.links), 0)
+
     def test_update_name(self):
         with self.tracer.start_as_current_span("root") as root:
             # name