Repository: open-telemetry/opentelemetry-python

Issue:
ParentBased sampler ignores sample decision of parent when creating span via implicit Context
When having a `ParentBased` sampler setup and creating a span via `tracer.start_span` without explicitly providing a `context` as parent, possible parent spans in the current `Context` are ignored [ignored](https://github.com/open-telemetry/opentelemetry-python/blob/v0.15b0/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py#L230). 
Since for implicit parents the passed `context` is always `None` the `ParentBased` sampler needs to consider also parent Spans in the current `Context` and, if available, respect their sampling flag instead of forwarding to the delegate sampler.




Discussion:

Resolving patch:
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py
--- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py
@@ -151,7 +151,7 @@ def should_sample(
         trace_state: "TraceState" = None,
     ) -> "SamplingResult":
         if self._decision is Decision.DROP:
-            return SamplingResult(self._decision)
+            attributes = None
         return SamplingResult(self._decision, attributes, trace_state)
 
     def get_description(self) -> str:
@@ -209,8 +209,8 @@ def should_sample(
         if trace_id & self.TRACE_ID_LIMIT < self.bound:
             decision = Decision.RECORD_AND_SAMPLE
         if decision is Decision.DROP:
-            return SamplingResult(decision)
-        return SamplingResult(decision, attributes)
+            attributes = None
+        return SamplingResult(decision, attributes, trace_state)
 
     def get_description(self) -> str:
         return "TraceIdRatioBased{{{}}}".format(self._rate)
@@ -238,18 +238,16 @@ def should_sample(
         links: Sequence["Link"] = None,
         trace_state: "TraceState" = None,
     ) -> "SamplingResult":
-        if parent_context is not None:
-            parent_span_context = get_current_span(
-                parent_context
-            ).get_span_context()
-            # only drop if parent exists and is not a root span
-            if (
-                parent_span_context is not None
-                and parent_span_context.is_valid
-                and not parent_span_context.trace_flags.sampled
-            ):
-                return SamplingResult(Decision.DROP)
-            return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes)
+        parent_span_context = get_current_span(
+            parent_context
+        ).get_span_context()
+        # respect the sampling flag of the parent if present
+        if parent_span_context is not None and parent_span_context.is_valid:
+            decision = Decision.RECORD_AND_SAMPLE
+            if not parent_span_context.trace_flags.sampled:
+                decision = Decision.DROP
+                attributes = None
+            return SamplingResult(decision, attributes, trace_state)
 
         return self._delegate.should_sample(
             parent_context=parent_context,


Test patch:
diff --git a/opentelemetry-sdk/tests/trace/test_sampling.py b/opentelemetry-sdk/tests/trace/test_sampling.py
--- a/opentelemetry-sdk/tests/trace/test_sampling.py
+++ b/opentelemetry-sdk/tests/trace/test_sampling.py
@@ -12,9 +12,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import contextlib
 import sys
+import typing
 import unittest
 
+from opentelemetry import context as context_api
 from opentelemetry import trace
 from opentelemetry.sdk.trace import sampling
 
@@ -62,161 +65,173 @@ def test_ctr(self):
 
 
 class TestSampler(unittest.TestCase):
-    def test_always_on(self):
-        no_record_always_on = sampling.ALWAYS_ON.should_sample(
+    def _create_parent(
+        self, trace_flags: trace.TraceFlags, is_remote=False
+    ) -> typing.Optional[context_api.Context]:
+        if trace_flags is None:
+            return None
+        return trace.set_span_in_context(
+            self._create_parent_span(trace_flags, is_remote)
+        )
+
+    @staticmethod
+    def _create_parent_span(
+        trace_flags: trace.TraceFlags, is_remote=False
+    ) -> trace.DefaultSpan:
+        return trace.DefaultSpan(
             trace.SpanContext(
-                0xDEADBEEF, 0xDEADBEF0, is_remote=False, trace_flags=TO_DEFAULT
-            ),
-            0xDEADBEF1,
-            0xDEADBEF2,
-            {"unsampled parent": "sampling on"},
-        )
-        self.assertTrue(no_record_always_on.decision.is_sampled())
-        self.assertEqual(
-            no_record_always_on.attributes, {"unsampled parent": "sampling on"}
+                0xDEADBEEF,
+                0xDEADBEF0,
+                is_remote=is_remote,
+                trace_flags=trace_flags,
+            )
         )
 
-        sampled_always_on = sampling.ALWAYS_ON.should_sample(
-            trace.SpanContext(
-                0xDEADBEEF, 0xDEADBEF0, is_remote=False, trace_flags=TO_SAMPLED
-            ),
-            0xDEADBEF1,
-            0xDEADBEF2,
-            {"sampled parent": "sampling on"},
-        )
-        self.assertTrue(no_record_always_on.decision.is_sampled())
-        self.assertEqual(
-            sampled_always_on.attributes, {"sampled parent": "sampling on"}
-        )
+    def test_always_on(self):
+        trace_state = trace.TraceState({"key": "value"})
+        test_data = (TO_DEFAULT, TO_SAMPLED, None)
+
+        for trace_flags in test_data:
+            with self.subTest(trace_flags=trace_flags):
+                context = self._create_parent(trace_flags)
+                sample_result = sampling.ALWAYS_ON.should_sample(
+                    context,
+                    0xDEADBEF1,
+                    "sampling on",
+                    attributes={"sampled.expect": "true"},
+                    trace_state=trace_state,
+                )
+
+                self.assertTrue(sample_result.decision.is_sampled())
+                self.assertEqual(
+                    sample_result.attributes, {"sampled.expect": "true"}
+                )
+                self.assertEqual(sample_result.trace_state, trace_state)
 
     def test_always_off(self):
-        no_record_always_off = sampling.ALWAYS_OFF.should_sample(
-            trace.SpanContext(
-                0xDEADBEEF, 0xDEADBEF0, is_remote=False, trace_flags=TO_DEFAULT
-            ),
-            0xDEADBEF1,
-            0xDEADBEF2,
-            "unsampled parent, sampling off",
-        )
-        self.assertFalse(no_record_always_off.decision.is_sampled())
-        self.assertEqual(no_record_always_off.attributes, {})
+        trace_state = trace.TraceState({"key": "value"})
+        test_data = (TO_DEFAULT, TO_SAMPLED, None)
+        for trace_flags in test_data:
+            with self.subTest(trace_flags=trace_flags):
+                context = self._create_parent(trace_flags)
+                sample_result = sampling.ALWAYS_OFF.should_sample(
+                    context,
+                    0xDEADBEF1,
+                    "sampling off",
+                    attributes={"sampled.expect": "false"},
+                    trace_state=trace_state,
+                )
+                self.assertFalse(sample_result.decision.is_sampled())
+                self.assertEqual(sample_result.attributes, {})
+                self.assertEqual(sample_result.trace_state, trace_state)
 
-        sampled_always_on = sampling.ALWAYS_OFF.should_sample(
-            trace.SpanContext(
-                0xDEADBEEF, 0xDEADBEF0, is_remote=False, trace_flags=TO_SAMPLED
-            ),
+    def test_default_on(self):
+        trace_state = trace.TraceState({"key": "value"})
+        context = self._create_parent(trace_flags=TO_DEFAULT)
+        sample_result = sampling.DEFAULT_ON.should_sample(
+            context,
             0xDEADBEF1,
-            0xDEADBEF2,
-            "sampled parent, sampling off",
+            "unsampled parent, sampling on",
+            attributes={"sampled.expect": "false"},
+            trace_state=trace_state,
         )
-        self.assertFalse(sampled_always_on.decision.is_sampled())
-        self.assertEqual(sampled_always_on.attributes, {})
+        self.assertFalse(sample_result.decision.is_sampled())
+        self.assertEqual(sample_result.attributes, {})
+        self.assertEqual(sample_result.trace_state, trace_state)
 
-    def test_default_on(self):
-        context = trace.set_span_in_context(
-            trace.DefaultSpan(
-                trace.SpanContext(
-                    0xDEADBEEF,
-                    0xDEADBEF0,
-                    is_remote=False,
-                    trace_flags=TO_DEFAULT,
-                )
-            )
-        )
-        no_record_default_on = sampling.DEFAULT_ON.should_sample(
-            context, 0xDEADBEF1, 0xDEADBEF2, "unsampled parent, sampling on",
-        )
-        self.assertFalse(no_record_default_on.decision.is_sampled())
-        self.assertEqual(no_record_default_on.attributes, {})
-
-        context = trace.set_span_in_context(
-            trace.DefaultSpan(
-                trace.SpanContext(
-                    0xDEADBEEF,
-                    0xDEADBEF0,
-                    is_remote=False,
-                    trace_flags=TO_SAMPLED,
-                )
-            )
-        )
-        sampled_default_on = sampling.DEFAULT_ON.should_sample(
-            context, 0xDEADBEF1, 0xDEADBEF2, {"sampled parent": "sampling on"},
-        )
-        self.assertTrue(sampled_default_on.decision.is_sampled())
-        self.assertEqual(
-            sampled_default_on.attributes, {"sampled parent": "sampling on"}
+        context = self._create_parent(trace_flags=TO_SAMPLED)
+        sample_result = sampling.DEFAULT_ON.should_sample(
+            context,
+            0xDEADBEF1,
+            "sampled parent, sampling on",
+            attributes={"sampled.expect": "true"},
+            trace_state=trace_state,
         )
+        self.assertTrue(sample_result.decision.is_sampled())
+        self.assertEqual(sample_result.attributes, {"sampled.expect": "true"})
+        self.assertEqual(sample_result.trace_state, trace_state)
 
-        default_on = sampling.DEFAULT_ON.should_sample(
-            None, 0xDEADBEF1, 0xDEADBEF2, {"sampled parent": "sampling on"},
-        )
-        self.assertTrue(default_on.decision.is_sampled())
-        self.assertEqual(
-            default_on.attributes, {"sampled parent": "sampling on"}
+        sample_result = sampling.DEFAULT_ON.should_sample(
+            None,
+            0xDEADBEF1,
+            "no parent, sampling on",
+            attributes={"sampled.expect": "true"},
+            trace_state=trace_state,
         )
+        self.assertTrue(sample_result.decision.is_sampled())
+        self.assertEqual(sample_result.attributes, {"sampled.expect": "true"})
+        self.assertEqual(sample_result.trace_state, trace_state)
 
     def test_default_off(self):
-        context = trace.set_span_in_context(
-            trace.DefaultSpan(
-                trace.SpanContext(
-                    0xDEADBEEF,
-                    0xDEADBEF0,
-                    is_remote=False,
-                    trace_flags=TO_DEFAULT,
-                )
-            )
-        )
-        no_record_default_off = sampling.DEFAULT_OFF.should_sample(
-            context, 0xDEADBEF1, 0xDEADBEF2, "unsampled parent, sampling off",
-        )
-        self.assertFalse(no_record_default_off.decision.is_sampled())
-        self.assertEqual(no_record_default_off.attributes, {})
-
-        context = trace.set_span_in_context(
-            trace.DefaultSpan(
-                trace.SpanContext(
-                    0xDEADBEEF,
-                    0xDEADBEF0,
-                    is_remote=False,
-                    trace_flags=TO_SAMPLED,
-                )
-            )
-        )
-        sampled_default_off = sampling.DEFAULT_OFF.should_sample(
-            context, 0xDEADBEF1, 0xDEADBEF2, {"sampled parent": "sampling on"},
+        trace_state = trace.TraceState({"key": "value"})
+        context = self._create_parent(trace_flags=TO_DEFAULT)
+        sample_result = sampling.DEFAULT_OFF.should_sample(
+            context,
+            0xDEADBEF1,
+            "unsampled parent, sampling off",
+            attributes={"sampled.expect", "false"},
+            trace_state=trace_state,
         )
-        self.assertTrue(sampled_default_off.decision.is_sampled())
-        self.assertEqual(
-            sampled_default_off.attributes, {"sampled parent": "sampling on"}
+        self.assertFalse(sample_result.decision.is_sampled())
+        self.assertEqual(sample_result.attributes, {})
+        self.assertEqual(sample_result.trace_state, trace_state)
+
+        context = self._create_parent(trace_flags=TO_SAMPLED)
+        sample_result = sampling.DEFAULT_OFF.should_sample(
+            context,
+            0xDEADBEF1,
+            "sampled parent, sampling on",
+            attributes={"sampled.expect": "true"},
+            trace_state=trace_state,
         )
+        self.assertTrue(sample_result.decision.is_sampled())
+        self.assertEqual(sample_result.attributes, {"sampled.expect": "true"})
+        self.assertEqual(sample_result.trace_state, trace_state)
 
         default_off = sampling.DEFAULT_OFF.should_sample(
-            None, 0xDEADBEF1, 0xDEADBEF2, "unsampled parent, sampling off",
+            None,
+            0xDEADBEF1,
+            "unsampled parent, sampling off",
+            attributes={"sampled.expect": "false"},
+            trace_state=trace_state,
         )
         self.assertFalse(default_off.decision.is_sampled())
         self.assertEqual(default_off.attributes, {})
+        self.assertEqual(default_off.trace_state, trace_state)
 
     def test_probability_sampler(self):
+        trace_state = trace.TraceState({"key": "value"})
         sampler = sampling.TraceIdRatioBased(0.5)
 
         # Check that we sample based on the trace ID if the parent context is
         # null
-        self.assertTrue(
-            sampler.should_sample(
-                None, 0x7FFFFFFFFFFFFFFF, 0xDEADBEEF, "span name"
-            ).decision.is_sampled()
-        )
-        self.assertFalse(
-            sampler.should_sample(
-                None, 0x8000000000000000, 0xDEADBEEF, "span name"
-            ).decision.is_sampled()
-        )
+        sampled_result = sampler.should_sample(
+            None,
+            0x7FFFFFFFFFFFFFFF,
+            "sampled true",
+            attributes={"sampled.expect": "true"},
+            trace_state=trace_state,
+        )
+        self.assertTrue(sampled_result.decision.is_sampled())
+        self.assertEqual(sampled_result.attributes, {"sampled.expect": "true"})
+        self.assertEqual(sampled_result.trace_state, trace_state)
+
+        not_sampled_result = sampler.should_sample(
+            None,
+            0x8000000000000000,
+            "sampled false",
+            attributes={"sampled.expect": "false"},
+            trace_state=trace_state,
+        )
+        self.assertFalse(not_sampled_result.decision.is_sampled())
+        self.assertEqual(not_sampled_result.attributes, {})
+        self.assertEqual(not_sampled_result.trace_state, trace_state)
 
     def test_probability_sampler_zero(self):
         default_off = sampling.TraceIdRatioBased(0.0)
         self.assertFalse(
             default_off.should_sample(
-                None, 0x0, 0xDEADBEEF, "span name"
+                None, 0x0, "span name"
             ).decision.is_sampled()
         )
 
@@ -224,7 +239,7 @@ def test_probability_sampler_one(self):
         default_off = sampling.TraceIdRatioBased(1.0)
         self.assertTrue(
             default_off.should_sample(
-                None, 0xFFFFFFFFFFFFFFFF, 0xDEADBEEF, "span name"
+                None, 0xFFFFFFFFFFFFFFFF, "span name"
             ).decision.is_sampled()
         )
 
@@ -236,12 +251,12 @@ def test_probability_sampler_limits(self):
         almost_always_off = sampling.TraceIdRatioBased(2 ** -64)
         self.assertTrue(
             almost_always_off.should_sample(
-                None, 0x0, 0xDEADBEEF, "span name"
+                None, 0x0, "span name"
             ).decision.is_sampled()
         )
         self.assertFalse(
             almost_always_off.should_sample(
-                None, 0x1, 0xDEADBEEF, "span name"
+                None, 0x1, "span name"
             ).decision.is_sampled()
         )
         self.assertEqual(
@@ -259,7 +274,7 @@ def test_probability_sampler_limits(self):
         almost_always_on = sampling.TraceIdRatioBased(1 - 2 ** -64)
         self.assertTrue(
             almost_always_on.should_sample(
-                None, 0xFFFFFFFFFFFFFFFE, 0xDEADBEEF, "span name"
+                None, 0xFFFFFFFFFFFFFFFE, "span name"
             ).decision.is_sampled()
         )
 
@@ -271,9 +286,8 @@ def test_probability_sampler_limits(self):
         #     almost_always_on.should_sample(
         #         None,
         #         0xFFFFFFFFFFFFFFFF,
-        #         0xDEADBEEF,
         #         "span name",
-        #     ).sampled
+        #     ).decision.is_sampled()
         # )
         # self.assertEqual(
         #     sampling.TraceIdRatioBased.get_bound_for_rate(1 - 2 ** -64)),
@@ -287,7 +301,7 @@ def test_probability_sampler_limits(self):
         )
         self.assertFalse(
             almost_almost_always_on.should_sample(
-                None, 0xFFFFFFFFFFFFFFFF, 0xDEADBEEF, "span name"
+                None, 0xFFFFFFFFFFFFFFFF, "span name"
             ).decision.is_sampled()
         )
         # Check that the higest effective sampling rate is actually lower than
@@ -297,47 +311,78 @@ def test_probability_sampler_limits(self):
             almost_almost_always_on.bound, 0xFFFFFFFFFFFFFFFF,
         )
 
-    def test_parent_based(self):
+    def exec_parent_based(self, parent_sampling_context):
+        trace_state = trace.TraceState({"key": "value"})
         sampler = sampling.ParentBased(sampling.ALWAYS_ON)
-        context = trace.set_span_in_context(
-            trace.DefaultSpan(
-                trace.SpanContext(
-                    0xDEADBEF0,
-                    0xDEADBEF1,
-                    is_remote=False,
-                    trace_flags=TO_DEFAULT,
-                )
+        with parent_sampling_context(
+            self._create_parent_span(trace_flags=TO_DEFAULT)
+        ) as context:
+            # Check that the sampling decision matches the parent context if given
+            not_sampled_result = sampler.should_sample(
+                context,
+                0x7FFFFFFFFFFFFFFF,
+                "unsampled parent, sampling on",
+                attributes={"sampled": "false"},
+                trace_state=trace_state,
             )
-        )
-        # Check that the sampling decision matches the parent context if given
-        self.assertFalse(
-            sampler.should_sample(
-                context, 0x7FFFFFFFFFFFFFFF, 0xDEADBEEF, "span name",
-            ).decision.is_sampled()
-        )
-
-        context = trace.set_span_in_context(
-            trace.DefaultSpan(
-                trace.SpanContext(
-                    0xDEADBEF0,
-                    0xDEADBEF1,
-                    is_remote=False,
-                    trace_flags=TO_SAMPLED,
-                )
+            self.assertFalse(not_sampled_result.decision.is_sampled())
+            self.assertEqual(not_sampled_result.attributes, {})
+            self.assertEqual(not_sampled_result.trace_state, trace_state)
+
+        with parent_sampling_context(
+            self._create_parent_span(trace_flags=TO_SAMPLED)
+        ) as context:
+            sampler2 = sampling.ParentBased(sampling.ALWAYS_OFF)
+            sampled_result = sampler2.should_sample(
+                context,
+                0x8000000000000000,
+                "sampled parent, sampling off",
+                attributes={"sampled": "true"},
+                trace_state=trace_state,
             )
-        )
-        sampler2 = sampling.ParentBased(sampling.ALWAYS_OFF)
-        self.assertTrue(
-            sampler2.should_sample(
-                context, 0x8000000000000000, 0xDEADBEEF, "span name",
-            ).decision.is_sampled()
-        )
+            self.assertTrue(sampled_result.decision.is_sampled())
+            self.assertEqual(sampled_result.attributes, {"sampled": "true"})
+            self.assertEqual(sampled_result.trace_state, trace_state)
+
+        # for root span follow decision of delegate sampler
+        with parent_sampling_context(trace.INVALID_SPAN) as context:
+            sampler3 = sampling.ParentBased(sampling.ALWAYS_OFF)
+            not_sampled_result = sampler3.should_sample(
+                context,
+                0x8000000000000000,
+                "parent, sampling off",
+                attributes={"sampled": "false"},
+                trace_state=trace_state,
+            )
+            self.assertFalse(not_sampled_result.decision.is_sampled())
+            self.assertEqual(not_sampled_result.attributes, {})
+            self.assertEqual(not_sampled_result.trace_state, trace_state)
+
+        with parent_sampling_context(trace.INVALID_SPAN) as context:
+            sampler4 = sampling.ParentBased(sampling.ALWAYS_ON)
+            sampled_result = sampler4.should_sample(
+                context,
+                0x8000000000000000,
+                "no parent, sampling on",
+                attributes={"sampled": "true"},
+                trace_state=trace_state,
+            )
+            self.assertTrue(sampled_result.decision.is_sampled())
+            self.assertEqual(sampled_result.attributes, {"sampled": "true"})
+            self.assertEqual(sampled_result.trace_state, trace_state)
 
-        # root span always sampled for parentbased
-        context = trace.set_span_in_context(trace.INVALID_SPAN)
-        sampler3 = sampling.ParentBased(sampling.ALWAYS_OFF)
-        self.assertTrue(
-            sampler3.should_sample(
-                context, 0x8000000000000000, 0xDEADBEEF, "span name",
-            ).decision.is_sampled()
-        )
+    def test_parent_based_explicit_parent_context(self):
+        @contextlib.contextmanager
+        def explicit_parent_context(span: trace.Span):
+            yield trace.set_span_in_context(span)
+
+        self.exec_parent_based(explicit_parent_context)
+
+    def test_parent_based_implicit_parent_context(self):
+        @contextlib.contextmanager
+        def implicit_parent_context(span: trace.Span):
+            token = context_api.attach(trace.set_span_in_context(span))
+            yield None
+            context_api.detach(token)
+
+        self.exec_parent_based(implicit_parent_context)