diff --git a/CHANGELOG.md b/CHANGELOG.md index e3091794e0b..40bbeb263a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4798](https://github.com/open-telemetry/opentelemetry-python/pull/4798)) - Silence events API warnings for internal users ([#4847](https://github.com/open-telemetry/opentelemetry-python/pull/4847)) +- `opentelemetry-api`, `opentelemetry-sdk`: add support for 'random-trace-id' flags in W3C traceparent header trace flags + ([#4837](https://github.com/open-telemetry/opentelemetry-python/pull/4854) ## Version 1.39.0/0.60b0 (2025-12-03) diff --git a/opentelemetry-api/src/opentelemetry/trace/span.py b/opentelemetry-api/src/opentelemetry/trace/span.py index b0cda475e2f..7ccc38d6205 100644 --- a/opentelemetry-api/src/opentelemetry/trace/span.py +++ b/opentelemetry-api/src/opentelemetry/trace/span.py @@ -197,17 +197,21 @@ def __exit__( class TraceFlags(int): """A bitmask that represents options specific to the trace. - The only supported option is the "sampled" flag (``0x01``). If set, this - flag indicates that the trace may have been sampled upstream. + Supported flags: + - "sampled" (``0x01``): Indicates the trace may have been sampled upstream. + - "random-trace-id" (``0x02``): Indicates the trace ID was generated + randomly, with at least the 7 rightmost bytes (56 bits) selected with + uniform distribution. See the `W3C Trace Context - Traceparent`_ spec for details. .. _W3C Trace Context - Traceparent: - https://www.w3.org/TR/trace-context/#trace-flags + https://www.w3.org/TR/trace-context-2/#trace-flags """ DEFAULT = 0x00 SAMPLED = 0x01 + RANDOM_TRACE_ID = 0x02 @classmethod def get_default(cls) -> "TraceFlags": @@ -217,6 +221,10 @@ def get_default(cls) -> "TraceFlags": def sampled(self) -> bool: return bool(self & TraceFlags.SAMPLED) + @property + def random_trace_id(self) -> bool: + return bool(self & TraceFlags.RANDOM_TRACE_ID) + DEFAULT_TRACE_OPTIONS = TraceFlags.get_default() diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 0e7e1f6db3b..ce51f671095 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -1169,6 +1169,12 @@ def start_span( # pylint: disable=too-many-locals if sampling_result.decision.is_sampled() else trace_api.TraceFlags(trace_api.TraceFlags.DEFAULT) ) + + if self.id_generator.is_trace_id_random(): + trace_flags = trace_api.TraceFlags( + trace_flags | trace_api.TraceFlags.RANDOM_TRACE_ID + ) + span_context = trace_api.SpanContext( trace_id, self.id_generator.generate_span_id(), diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/id_generator.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/id_generator.py index cd1f89bcde2..d451e111816 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/id_generator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/id_generator.py @@ -31,16 +31,36 @@ def generate_span_id(self) -> int: def generate_trace_id(self) -> int: """Get a new trace ID. - Implementations should at least make the 64 least significant bits + Implementations should at least make the 56 least significant bits uniformly random. Samplers like the `TraceIdRatioBased` sampler rely on this randomness to make sampling decisions. + If the implementation does randomly generate the 56 least significant bits, + it should also implement `is_trace_id_random` to return True. + See `the specification on TraceIdRatioBased `_. Returns: A 128-bit int for use as a trace ID """ + # pylint: disable=no-self-use + def is_trace_id_random(self) -> bool: + """Indicates whether generated trace IDs are random. + + When True, the `trace-id` field will have the `random-trace-id` flag set + in the W3C traceparent header. Per the W3C Trace Context specification, + this indicates that at least the 7 rightmost bytes (56 bits) of the + trace ID were generated randomly with uniform distribution. + + See `the W3C Trace Context specification `_. + + Returns: + True if this generator produces random IDs, False otherwise. + """ + # By default, return False for backwards compatibility. + return False + class RandomIdGenerator(IdGenerator): """The default ID generator for TracerProvider which randomly generates all @@ -58,3 +78,6 @@ def generate_trace_id(self) -> int: while trace_id == trace.INVALID_TRACE_ID: trace_id = random.getrandbits(128) return trace_id + + def is_trace_id_random(self) -> bool: + return True diff --git a/opentelemetry-sdk/tests/test_configurator.py b/opentelemetry-sdk/tests/test_configurator.py index 8edc9190dac..f7f0f67838f 100644 --- a/opentelemetry-sdk/tests/test_configurator.py +++ b/opentelemetry-sdk/tests/test_configurator.py @@ -307,6 +307,9 @@ def generate_span_id(self): def generate_trace_id(self): pass + def is_trace_id_random(self): + return False + class TestTraceInit(TestCase): def setUp(self): diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index b83b000f4d1..f13463d1ab1 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -212,14 +212,7 @@ def test_default_sampler(self): child_span = tracer.start_span(name="child span", context=ctx) self.assertIsInstance(child_span, trace.Span) self.assertTrue(root_span.context.trace_flags.sampled) - self.assertEqual( - root_span.get_span_context().trace_flags, - trace_api.TraceFlags.SAMPLED, - ) - self.assertEqual( - child_span.get_span_context().trace_flags, - trace_api.TraceFlags.SAMPLED, - ) + self.assertTrue(root_span.get_span_context().trace_flags.sampled) def test_default_sampler_type(self): tracer_provider = trace.TracerProvider() @@ -237,14 +230,8 @@ def test_sampler_no_sampling(self, _get_from_env_or_default): self.assertIsInstance(root_span, trace_api.NonRecordingSpan) child_span = tracer.start_span(name="child span", context=ctx) self.assertIsInstance(child_span, trace_api.NonRecordingSpan) - self.assertEqual( - root_span.get_span_context().trace_flags, - trace_api.TraceFlags.DEFAULT, - ) - self.assertEqual( - child_span.get_span_context().trace_flags, - trace_api.TraceFlags.DEFAULT, - ) + self.assertFalse(root_span.get_span_context().trace_flags.sampled) + self.assertFalse(child_span.get_span_context().trace_flags.sampled) self.assertFalse(_get_from_env_or_default.called) @mock.patch.dict("os.environ", {OTEL_TRACES_SAMPLER: "always_off"}) @@ -464,9 +451,8 @@ def test_start_span_explicit(self): other_parent.get_span_context().trace_state, child_context.trace_state, ) - self.assertEqual( - other_parent.get_span_context().trace_flags, - child_context.trace_flags, + self.assertTrue( + other_parent.get_span_context().trace_flags.sampled ) # Verify start_span() did not set the current span. @@ -827,10 +813,7 @@ def test_sampling_attributes(self): self.assertEqual(len(root.attributes), 2) self.assertEqual(root.attributes["sampler-attr"], "sample-val") self.assertEqual(root.attributes["attr-in-both"], "decision-attr") - self.assertEqual( - root.get_span_context().trace_flags, - trace_api.TraceFlags.SAMPLED, - ) + self.assertTrue(root.get_span_context().trace_flags.sampled) def test_events(self): self.assertEqual(trace_api.get_current_span(), trace_api.INVALID_SPAN) @@ -2066,6 +2049,9 @@ def test_constant_default(self): def test_constant_sampled(self): self.assertEqual(trace_api.TraceFlags.SAMPLED, 1) + def test_constant_random_trace_id(self): + self.assertEqual(trace_api.TraceFlags.RANDOM_TRACE_ID, 2) + def test_get_default(self): self.assertEqual( trace_api.TraceFlags.get_default(), trace_api.TraceFlags.DEFAULT @@ -2077,6 +2063,14 @@ def test_sampled_true(self): def test_sampled_false(self): self.assertFalse(trace_api.TraceFlags(0xF0).sampled) + def test_random_trace_id_true(self): + self.assertTrue(trace_api.TraceFlags(0xF2).random_trace_id) + self.assertTrue(trace_api.TraceFlags(0xF3).random_trace_id) + + def test_random_trace_id_false(self): + self.assertFalse(trace_api.TraceFlags(0xF0).random_trace_id) + self.assertFalse(trace_api.TraceFlags(0xF1).random_trace_id) + def test_constant_default_trace_options(self): self.assertEqual( trace_api.DEFAULT_TRACE_OPTIONS, trace_api.TraceFlags.DEFAULT @@ -2214,3 +2208,7 @@ def test_generate_trace_id_avoids_invalid(self, mock_getrandbits): self.assertNotEqual(trace_id, trace_api.INVALID_TRACE_ID) mock_getrandbits.assert_any_call(128) self.assertEqual(mock_getrandbits.call_count, 2) + + def test_is_trace_id_random_returns_true(self): + generator = RandomIdGenerator() + self.assertTrue(generator.is_trace_id_random()) diff --git a/tests/opentelemetry-test-utils/src/opentelemetry/test/wsgitestutil.py b/tests/opentelemetry-test-utils/src/opentelemetry/test/wsgitestutil.py index 908b1d41847..9e501b38489 100644 --- a/tests/opentelemetry-test-utils/src/opentelemetry/test/wsgitestutil.py +++ b/tests/opentelemetry-test-utils/src/opentelemetry/test/wsgitestutil.py @@ -48,7 +48,8 @@ def assertTraceResponseHeaderMatchesSpan(self, headers, span): # pylint: disabl trace_id = trace.format_trace_id(span.get_span_context().trace_id) span_id = trace.format_span_id(span.get_span_context().span_id) + trace_flags = span.get_span_context().trace_flags self.assertEqual( - f"00-{trace_id}-{span_id}-01", + f"00-{trace_id}-{span_id}-{trace_flags:02x}", headers["traceresponse"], )