From fa2146d429b28e4a7ecaf533f1fa5f4ac1e2d645 Mon Sep 17 00:00:00 2001 From: Mario Jonke Date: Thu, 19 Nov 2020 08:39:33 +0100 Subject: [PATCH 1/2] Fix ParentBased sampler for implicit parent spans consider the sample decision of implicit parent spans (when creating a span without explicitly providing a context) instead of forwarding to the delegating sampler. --- .../src/opentelemetry/sdk/trace/sampling.py | 16 ++-- .../tests/trace/test_sampling.py | 76 +++++++++++++------ 2 files changed, 57 insertions(+), 35 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py index ffa51506ffa..ca3039e1cc3 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py @@ -238,16 +238,12 @@ 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 - ): + 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: + if not parent_span_context.trace_flags.sampled: return SamplingResult(Decision.DROP) return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes) diff --git a/opentelemetry-sdk/tests/trace/test_sampling.py b/opentelemetry-sdk/tests/trace/test_sampling.py index d51a59c1066..f5c6e1a587a 100644 --- a/opentelemetry-sdk/tests/trace/test_sampling.py +++ b/opentelemetry-sdk/tests/trace/test_sampling.py @@ -12,9 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import contextlib import sys import unittest +from opentelemetry import context as context_api from opentelemetry import trace from opentelemetry.sdk.trace import sampling @@ -297,9 +299,9 @@ 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): sampler = sampling.ParentBased(sampling.ALWAYS_ON) - context = trace.set_span_in_context( + with parent_sampling_context( trace.DefaultSpan( trace.SpanContext( 0xDEADBEF0, @@ -308,15 +310,15 @@ def test_parent_based(self): trace_flags=TO_DEFAULT, ) ) - ) - # Check that the sampling decision matches the parent context if given - self.assertFalse( - sampler.should_sample( - context, 0x7FFFFFFFFFFFFFFF, 0xDEADBEEF, "span name", - ).decision.is_sampled() - ) + ) as context: + # 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( + with parent_sampling_context( trace.DefaultSpan( trace.SpanContext( 0xDEADBEF0, @@ -325,19 +327,43 @@ def test_parent_based(self): trace_flags=TO_SAMPLED, ) ) - ) - sampler2 = sampling.ParentBased(sampling.ALWAYS_OFF) - self.assertTrue( - sampler2.should_sample( - context, 0x8000000000000000, 0xDEADBEEF, "span name", - ).decision.is_sampled() - ) + ) as context: + sampler2 = sampling.ParentBased(sampling.ALWAYS_OFF) + self.assertTrue( + sampler2.should_sample( + context, 0x8000000000000000, 0xDEADBEEF, "span name", + ).decision.is_sampled() + ) - # 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() - ) + # for root span follow decision of delegate sampler + with parent_sampling_context(trace.INVALID_SPAN) as context: + sampler3 = sampling.ParentBased(sampling.ALWAYS_OFF) + self.assertFalse( + sampler3.should_sample( + context, 0x8000000000000000, 0xDEADBEEF, "span name", + ).decision.is_sampled() + ) + + with parent_sampling_context(trace.INVALID_SPAN) as context: + sampler4 = sampling.ParentBased(sampling.ALWAYS_ON) + self.assertTrue( + sampler4.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) From d5f1a69e3123c2c230a2af8e3a00bc21883c707b Mon Sep 17 00:00:00 2001 From: Mario Jonke Date: Thu, 19 Nov 2020 08:56:37 +0100 Subject: [PATCH 2/2] Fix trace_state erasure for dropped spans * samplers did not return the trace_state in the sampling result when a span was dropped. This caused the trace_state extracted from a remote parent span to be erased and further context propagation to break. * fix also TraceIdRatioBased which erased the trace_state independent of the sampling outcome. --- opentelemetry-sdk/CHANGELOG.md | 3 + .../src/opentelemetry/sdk/trace/sampling.py | 12 +- .../tests/trace/test_sampling.py | 343 +++++++++--------- 3 files changed, 191 insertions(+), 167 deletions(-) diff --git a/opentelemetry-sdk/CHANGELOG.md b/opentelemetry-sdk/CHANGELOG.md index 851073bcd51..a09a4c32e37 100644 --- a/opentelemetry-sdk/CHANGELOG.md +++ b/opentelemetry-sdk/CHANGELOG.md @@ -12,6 +12,9 @@ ([#1373](https://github.com/open-telemetry/opentelemetry-python/pull/1373)) - Rename Meter class to Accumulator in Metrics SDK ([#1372](https://github.com/open-telemetry/opentelemetry-python/pull/1372)) +- Fix `ParentBased` sampler for implicit parent spans. Fix also `trace_state` + erasure for dropped spans or spans sampled by the `TraceIdRatioBased` sampler. + ([#1394](https://github.com/open-telemetry/opentelemetry-python/pull/1394)) ## Version 0.15b0 diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py index ca3039e1cc3..82d2cebaa51 100644 --- 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) @@ -243,9 +243,11 @@ def should_sample( ).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: - return SamplingResult(Decision.DROP) - return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes) + decision = Decision.DROP + attributes = None + return SamplingResult(decision, attributes, trace_state) return self._delegate.should_sample( parent_context=parent_context, diff --git a/opentelemetry-sdk/tests/trace/test_sampling.py b/opentelemetry-sdk/tests/trace/test_sampling.py index f5c6e1a587a..f6d77ab04ba 100644 --- a/opentelemetry-sdk/tests/trace/test_sampling.py +++ b/opentelemetry-sdk/tests/trace/test_sampling.py @@ -14,6 +14,7 @@ import contextlib import sys +import typing import unittest from opentelemetry import context as context_api @@ -64,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() ) @@ -226,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() ) @@ -238,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( @@ -261,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() ) @@ -273,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)), @@ -289,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 @@ -300,57 +312,64 @@ def test_probability_sampler_limits(self): ) def exec_parent_based(self, parent_sampling_context): + trace_state = trace.TraceState({"key": "value"}) sampler = sampling.ParentBased(sampling.ALWAYS_ON) with parent_sampling_context( - trace.DefaultSpan( - trace.SpanContext( - 0xDEADBEF0, - 0xDEADBEF1, - is_remote=False, - trace_flags=TO_DEFAULT, - ) - ) + self._create_parent_span(trace_flags=TO_DEFAULT) ) as context: # Check that the sampling decision matches the parent context if given - self.assertFalse( - sampler.should_sample( - context, 0x7FFFFFFFFFFFFFFF, 0xDEADBEEF, "span name", - ).decision.is_sampled() + not_sampled_result = sampler.should_sample( + context, + 0x7FFFFFFFFFFFFFFF, + "unsampled parent, sampling on", + 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.DefaultSpan( - trace.SpanContext( - 0xDEADBEF0, - 0xDEADBEF1, - is_remote=False, - trace_flags=TO_SAMPLED, - ) - ) + self._create_parent_span(trace_flags=TO_SAMPLED) ) as context: sampler2 = sampling.ParentBased(sampling.ALWAYS_OFF) - self.assertTrue( - sampler2.should_sample( - context, 0x8000000000000000, 0xDEADBEEF, "span name", - ).decision.is_sampled() + sampled_result = sampler2.should_sample( + context, + 0x8000000000000000, + "sampled parent, sampling off", + 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) # for root span follow decision of delegate sampler with parent_sampling_context(trace.INVALID_SPAN) as context: sampler3 = sampling.ParentBased(sampling.ALWAYS_OFF) - self.assertFalse( - sampler3.should_sample( - context, 0x8000000000000000, 0xDEADBEEF, "span name", - ).decision.is_sampled() + 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) - self.assertTrue( - sampler4.should_sample( - context, 0x8000000000000000, 0xDEADBEEF, "span name", - ).decision.is_sampled() + 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) def test_parent_based_explicit_parent_context(self): @contextlib.contextmanager