From fbde4a7026681a010e6af0d0379f0db7639b54ec Mon Sep 17 00:00:00 2001 From: hangy Date: Sun, 6 Apr 2025 12:29:17 +0200 Subject: [PATCH 01/12] feat(tracing): add W3C traceparent support and extraction functions for incoming requests --- sentry_sdk/tracing.py | 9 ++++++ sentry_sdk/tracing_utils.py | 47 +++++++++++++++++++++++++++++ tests/test_propagationcontext.py | 48 ++++++++++++++++++++++++++++++ tests/tracing/test_http_headers.py | 19 +++++++++++- 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 13d9f63d5e..18d00ec1dd 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -126,6 +126,7 @@ class TransactionKwargs(SpanKwargs, total=False): BAGGAGE_HEADER_NAME = "baggage" SENTRY_TRACE_HEADER_NAME = "sentry-trace" +W3C_TRACE_HEADER_NAME = "traceparent" # Transaction source @@ -509,7 +510,14 @@ def continue_from_headers( if sentrytrace_kwargs is not None: kwargs.update(sentrytrace_kwargs) + else: + w3c_traceparent_kwargs = extract_w3c_traceparent_data( + headers.get(W3C_TRACE_HEADER_NAME) + ) + if w3c_traceparent_kwargs is not None: + kwargs.update(w3c_traceparent_kwargs) + if sentrytrace_kwargs is not None or w3c_traceparent_kwargs is not None: # If there's an incoming sentry-trace but no incoming baggage header, # for instance in traces coming from older SDKs, # baggage will be empty and immutable and won't be populated as head SDK. @@ -1347,6 +1355,7 @@ async def my_async_function(): Baggage, EnvironHeaders, extract_sentrytrace_data, + extract_w3c_traceparent_data, _generate_sample_rand, has_tracing_enabled, maybe_create_breadcrumbs_from_span, diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index ba56695740..aaab4ecef4 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -48,6 +48,14 @@ "[ \t]*$" # whitespace ) +W3C_TRACE_REGEX = re.compile( + "^[ \t]*" # whitespace + "[0-9]{2}?" # version + "-?([0-9a-f]{32})?" # trace_id + "-?([0-9a-f]{16})?" # span_id + "-?([01]{2})?" # trace-flags + "[ \t]*$" # whitespace +) # This is a normal base64 regex, modified to reflect that fact that we strip the # trailing = or == off @@ -341,6 +349,35 @@ def extract_sentrytrace_data(header): } +def extract_w3c_traceparent_data(header): + # type: (Optional[str]) -> Optional[Dict[str, Union[str, bool, None]]] + """ + Given a `traceparent` header string, return a dictionary of data. + """ + if not header or not header.startswith("00-"): + return None + + match = W3C_TRACE_REGEX.match(header) + if not match: + return None + + trace_id, parent_span_id, trace_flags = match.groups() + parent_sampled = None + + if trace_id: + trace_id = "{:032x}".format(int(trace_id, 16)) + if parent_span_id: + parent_span_id = "{:016x}".format(int(parent_span_id, 16)) + if trace_flags: + parent_sampled = trace_flags == "01" + + return { + "trace_id": trace_id, + "parent_span_id": parent_span_id, + "parent_sampled": parent_sampled, + } + + def _format_sql(cursor, sql): # type: (Any, str) -> Optional[str] @@ -422,6 +459,15 @@ def from_incoming_data(cls, incoming_data): propagation_context = PropagationContext() propagation_context.update(sentrytrace_data) + if sentry_trace_header is None: + w3c_trace_header = normalized_data.get(W3C_TRACE_HEADER_NAME) + if w3c_trace_header: + w3c_trace_data = extract_w3c_traceparent_data(w3c_trace_header) + if w3c_trace_data is not None: + if propagation_context is None: + propagation_context = PropagationContext() + propagation_context.update(w3c_trace_data) + if propagation_context is not None: propagation_context._fill_sample_rand() @@ -898,6 +944,7 @@ def _sample_rand_range(parent_sampled, sample_rate): BAGGAGE_HEADER_NAME, LOW_QUALITY_TRANSACTION_SOURCES, SENTRY_TRACE_HEADER_NAME, + W3C_TRACE_HEADER_NAME, ) if TYPE_CHECKING: diff --git a/tests/test_propagationcontext.py b/tests/test_propagationcontext.py index a0ce1094fa..3de3ff4182 100644 --- a/tests/test_propagationcontext.py +++ b/tests/test_propagationcontext.py @@ -180,3 +180,51 @@ def mock_random_class(_): ) assert ctx.dynamic_sampling_context["sample_rand"] == "0.999999" + + +def test_from_incoming_data_w3c_traceparent_converts_to_sentry_trace_sampled(): + """When the W3C traceparent header is present, we should convert it to the sentry-trace format, including the sampled trace-flags.""" + ctx = PropagationContext().from_incoming_data( + {"traceparent": "00-3ba9122f36ec607c1a4d63d1488e554d-27eacbbc7b490cb2-01"} + ) + + assert ctx._trace_id == "3ba9122f36ec607c1a4d63d1488e554d" + assert ctx.trace_id == "3ba9122f36ec607c1a4d63d1488e554d" + assert ctx._span_id is None # this will be set lazily + assert ctx.span_id is not None # this sets _span_id + assert ctx._span_id is not None + assert ctx.parent_span_id == "27eacbbc7b490cb2" + assert ctx.parent_sampled + + +def test_from_incoming_data_w3c_traceparent_converts_to_sentry_trace(): + """When the W3C traceparent header is present, we should convert it to the sentry-trace format.""" + ctx = PropagationContext().from_incoming_data( + {"traceparent": "00-0668500b0d3ccb9e2cdc4d29eee35549-1e27e03ddf9267c5"} + ) + + assert ctx._trace_id == "0668500b0d3ccb9e2cdc4d29eee35549" + assert ctx.trace_id == "0668500b0d3ccb9e2cdc4d29eee35549" + assert ctx._span_id is None # this will be set lazily + assert ctx.span_id is not None # this sets _span_id + assert ctx._span_id is not None + assert ctx.parent_span_id == "1e27e03ddf9267c5" + assert not ctx.parent_sampled + + +def test_from_incoming_data_sentry_over_w3c_traceparent(): + """When the W3C traceparent header and sentry-trace are present, we ignore the traceparent.""" + ctx = PropagationContext().from_incoming_data( + { + "sentry-trace": "7978114bb8610ea77c10c57bf4210b0b-e840a63563b45c5f", + "traceparent": "00-86e2ea0e89e250d07b40d1a8eb77cd6f-2fe04ec920d6eaf7-01", + } + ) + + assert ctx._trace_id == "7978114bb8610ea77c10c57bf4210b0b" + assert ctx.trace_id == "7978114bb8610ea77c10c57bf4210b0b" + assert ctx._span_id is None # this will be set lazily + assert ctx.span_id is not None # this sets _span_id + assert ctx._span_id is not None + assert ctx.parent_span_id == "e840a63563b45c5f" + assert not ctx.parent_sampled diff --git a/tests/tracing/test_http_headers.py b/tests/tracing/test_http_headers.py index 6a8467101e..4d49cf3493 100644 --- a/tests/tracing/test_http_headers.py +++ b/tests/tracing/test_http_headers.py @@ -3,7 +3,10 @@ import pytest from sentry_sdk.tracing import Transaction -from sentry_sdk.tracing_utils import extract_sentrytrace_data +from sentry_sdk.tracing_utils import ( + extract_sentrytrace_data, + extract_w3c_traceparent_data, +) @pytest.mark.parametrize("sampled", [True, False, None]) @@ -38,6 +41,20 @@ def test_sentrytrace_extraction(sampling_decision): } +@pytest.mark.parametrize("sampling_decision", [True, False]) +def test_w3c_traceparent_extraction(sampling_decision): + traceparent_header = ( + "00-d00afb0f1514f9337a4a921c514955db-903c8c4987adea4b-{}".format( + "01" if sampling_decision is True else "00" + ) + ) + assert extract_w3c_traceparent_data(traceparent_header) == { + "trace_id": "d00afb0f1514f9337a4a921c514955db", + "parent_span_id": "903c8c4987adea4b", + "parent_sampled": sampling_decision, + } + + def test_iter_headers(monkeypatch): monkeypatch.setattr( Transaction, From dc9b392e6412dc714c1c2a5d79bb6d2e278fadf0 Mon Sep 17 00:00:00 2001 From: hangy Date: Sun, 6 Apr 2025 12:46:32 +0200 Subject: [PATCH 02/12] feat(tracing): add W3C traceparent header support and corresponding tests --- sentry_sdk/tracing.py | 18 ++++- tests/tracing/test_http_headers.py | 12 ++++ tests/tracing/test_integration_tests.py | 95 +++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 18d00ec1dd..608c7481cb 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -531,7 +531,8 @@ def continue_from_headers( def iter_headers(self): # type: () -> Iterator[Tuple[str, str]] """ - Creates a generator which returns the span's ``sentry-trace`` and ``baggage`` headers. + Creates a generator which returns the span's ``sentry-trace`` and ``baggage`` headers, + as well as a ``traceparent`` header for W3C compatibility. If the span's containing transaction doesn't yet have a ``baggage`` value, this will cause one to be generated and stored. """ @@ -543,6 +544,7 @@ def iter_headers(self): return yield SENTRY_TRACE_HEADER_NAME, self.to_traceparent() + yield W3C_TRACE_HEADER_NAME, self.to_w3c_traceparent() baggage = self.containing_transaction.get_baggage().serialize() if baggage: @@ -588,6 +590,16 @@ def to_traceparent(self): return traceparent + def to_w3c_traceparent(self): + # type: () -> str + if self.sampled is True: + trace_flags = "01" + else: + trace_flags = "00" + + traceparent = "00-%s-%s-%s" % (self.trace_id, self.span_id, trace_flags) + return traceparent + def to_baggage(self): # type: () -> Optional[Baggage] """Returns the :py:class:`~sentry_sdk.tracing_utils.Baggage` @@ -1233,6 +1245,10 @@ def to_traceparent(self): # type: () -> str return "" + def to_w3c_traceparent(self): + # type: () -> str + return "" + def to_baggage(self): # type: () -> Optional[Baggage] return None diff --git a/tests/tracing/test_http_headers.py b/tests/tracing/test_http_headers.py index 4d49cf3493..c9a1097f96 100644 --- a/tests/tracing/test_http_headers.py +++ b/tests/tracing/test_http_headers.py @@ -62,6 +62,14 @@ def test_iter_headers(monkeypatch): mock.Mock(return_value="12312012123120121231201212312012-0415201309082013-0"), ) + monkeypatch.setattr( + Transaction, + "to_w3c_traceparent", + mock.Mock( + return_value="00-12312012123120121231201212312012-0415201309082013-00" + ), + ) + transaction = Transaction( name="/interactions/other-dogs/new-dog", op="greeting.sniff", @@ -71,3 +79,7 @@ def test_iter_headers(monkeypatch): assert ( headers["sentry-trace"] == "12312012123120121231201212312012-0415201309082013-0" ) + assert ( + headers["traceparent"] + == "00-12312012123120121231201212312012-0415201309082013-00" + ) diff --git a/tests/tracing/test_integration_tests.py b/tests/tracing/test_integration_tests.py index 61ef14b7d0..ad76fe368d 100644 --- a/tests/tracing/test_integration_tests.py +++ b/tests/tracing/test_integration_tests.py @@ -147,6 +147,101 @@ def test_continue_from_headers( assert message_payload["message"] == "hello" +@pytest.mark.parametrize("parent_sampled", [True, False, None]) +@pytest.mark.parametrize("sample_rate", [0.0, 1.0]) +def test_continue_from_w3c_headers( + sentry_init, capture_envelopes, parent_sampled, sample_rate +): + """ + Ensure data is actually passed along via headers, and that they are read + correctly. This test is similar to the one above, but uses W3C headers + instead of Sentry headers. + """ + sentry_init(traces_sample_rate=sample_rate) + envelopes = capture_envelopes() + + # make a parent transaction (normally this would be in a different service) + with start_transaction(name="hi", sampled=True if sample_rate == 0 else None): + with start_span() as old_span: + old_span.sampled = parent_sampled + headers = dict( + sentry_sdk.get_current_scope().iter_trace_propagation_headers(old_span) + ) + headers["baggage"] = ( + "other-vendor-value-1=foo;bar;baz, " + "sentry-trace_id=d055bff6ed16698222b464d97f980489, " + "sentry-public_key=49d0f7386ad645858ae85020e393bef3, " + "sentry-sample_rate=0.01337, sentry-user_id=Amelie, " + "other-vendor-value-2=foo;bar;" + ) + + # child transaction, to prove that we can read 'sentry-trace' header data correctly + child_transaction = Transaction.continue_from_headers(headers, name="WRONG") + assert child_transaction is not None + assert child_transaction.parent_sampled == parent_sampled + assert child_transaction.trace_id == old_span.trace_id + assert child_transaction.same_process_as_parent is False + assert child_transaction.parent_span_id == old_span.span_id + assert child_transaction.span_id != old_span.span_id + + baggage = child_transaction._baggage + assert baggage + assert not baggage.mutable + assert baggage.sentry_items == { + "public_key": "49d0f7386ad645858ae85020e393bef3", + "trace_id": "d055bff6ed16698222b464d97f980489", + "user_id": "Amelie", + "sample_rate": "0.01337", + } + + # add child transaction to the scope, to show that the captured message will + # be tagged with the trace id (since it happens while the transaction is + # open) + with start_transaction(child_transaction): + # change the transaction name from "WRONG" to make sure the change + # is reflected in the final data + sentry_sdk.get_current_scope().transaction = "ho" + capture_message("hello") + + if parent_sampled is False or (sample_rate == 0 and parent_sampled is None): + # in this case the child transaction won't be captured + trace1, message = envelopes + message_payload = message.get_event() + trace1_payload = trace1.get_transaction_event() + + assert trace1_payload["transaction"] == "hi" + else: + trace1, message, trace2 = envelopes + trace1_payload = trace1.get_transaction_event() + message_payload = message.get_event() + trace2_payload = trace2.get_transaction_event() + + assert trace1_payload["transaction"] == "hi" + assert trace2_payload["transaction"] == "ho" + + assert ( + trace1_payload["contexts"]["trace"]["trace_id"] + == trace2_payload["contexts"]["trace"]["trace_id"] + == child_transaction.trace_id + == message_payload["contexts"]["trace"]["trace_id"] + ) + + if parent_sampled is not None: + expected_sample_rate = str(float(parent_sampled)) + else: + expected_sample_rate = str(sample_rate) + + assert trace2.headers["trace"] == baggage.dynamic_sampling_context() + assert trace2.headers["trace"] == { + "public_key": "49d0f7386ad645858ae85020e393bef3", + "trace_id": "d055bff6ed16698222b464d97f980489", + "user_id": "Amelie", + "sample_rate": expected_sample_rate, + } + + assert message_payload["message"] == "hello" + + @pytest.mark.parametrize("sample_rate", [0.0, 1.0]) def test_propagate_traces_deprecation_warning(sentry_init, sample_rate): sentry_init(traces_sample_rate=sample_rate, propagate_traces=False) From 53da6a769da9bd78bef36d4e7be5a32695534819 Mon Sep 17 00:00:00 2001 From: hangy Date: Tue, 8 Apr 2025 23:57:29 +0200 Subject: [PATCH 03/12] test(tracing): enhance W3C traceparent tests with sampling flag assertions --- tests/tracing/test_http_headers.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/tracing/test_http_headers.py b/tests/tracing/test_http_headers.py index c9a1097f96..eea4eecda3 100644 --- a/tests/tracing/test_http_headers.py +++ b/tests/tracing/test_http_headers.py @@ -29,6 +29,27 @@ def test_to_traceparent(sampled): assert parts[2] == "1" if sampled is True else "0" # sampled +@pytest.mark.parametrize("sampled", [True, False, None]) +def test_to_w3c_traceparent(sampled): + transaction = Transaction( + name="/interactions/other-dogs/new-dog", + op="greeting.sniff", + trace_id="4a77088e323f137c4d96381f35b92cf6", + sampled=sampled, + ) + + traceparent = transaction.to_w3c_traceparent() + + parts = traceparent.split("-") + assert parts[0] == "00" # version + assert parts[1] == "4a77088e323f137c4d96381f35b92cf6" # trace_id + assert parts[2] == transaction.span_id # parent_span_id + if sampled is not True: + assert parts[3] == "00" # trace-flags + else: + assert parts[3] == "01" # trace-flags + + @pytest.mark.parametrize("sampling_decision", [True, False]) def test_sentrytrace_extraction(sampling_decision): sentrytrace_header = "12312012123120121231201212312012-0415201309082013-{}".format( From b944656c6a219238f7e6e9006ece2dc12a8acda5 Mon Sep 17 00:00:00 2001 From: hangy Date: Wed, 9 Apr 2025 00:12:57 +0200 Subject: [PATCH 04/12] test(tracing): enhance W3C traceparent tests with sampling flag assertions --- sentry_sdk/scope.py | 31 ++++++++++++++++++++++++- tests/tracing/test_integration_tests.py | 8 ++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index f346569255..1fff065b77 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -29,6 +29,7 @@ from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, + W3C_TRACE_HEADER_NAME, NoOpSpan, Span, Transaction, @@ -533,6 +534,30 @@ def get_traceparent(self, *args, **kwargs): # Fall back to isolation scope's traceparent. It always has one return self.get_isolation_scope().get_traceparent() + def _get_w3c_traceparent(self, *args, **kwargs): + # type: (Any, Any) -> Optional[str] + """ + Returns the W3C "traceparent" header from the + currently active span or the scopes Propagation Context. + """ + client = self.get_client() + + # If we have an active span, return traceparent from there + if has_tracing_enabled(client.options) and self.span is not None: + return self.span.to_w3c_traceparent() + + # If this scope has a propagation context, return traceparent from there + if self._propagation_context is not None: + traceparent = "00-%s-%s-%s" % ( + self._propagation_context.trace_id, + self._propagation_context.span_id, + "01" if self._propagation_context.parent_sampled is True else "00", + ) + return traceparent + + # Fall back to isolation scope's traceparent. It always has one + return self.get_isolation_scope()._get_w3c_traceparent() + def get_baggage(self, *args, **kwargs): # type: (Any, Any) -> Optional[Baggage] """ @@ -608,13 +633,17 @@ def trace_propagation_meta(self, *args, **kwargs): def iter_headers(self): # type: () -> Iterator[Tuple[str, str]] """ - Creates a generator which returns the `sentry-trace` and `baggage` headers from the Propagation Context. + Creates a generator which returns the `sentry-trace`, `traceparent`, and `baggage` headers from the Propagation Context. """ if self._propagation_context is not None: traceparent = self.get_traceparent() if traceparent is not None: yield SENTRY_TRACE_HEADER_NAME, traceparent + w3c_traceparent = self._get_w3c_traceparent() + if w3c_traceparent is not None: + yield W3C_TRACE_HEADER_NAME, w3c_traceparent + dsc = self.get_dynamic_sampling_context() if dsc is not None: baggage = Baggage(dsc).serialize() diff --git a/tests/tracing/test_integration_tests.py b/tests/tracing/test_integration_tests.py index ad76fe368d..e0f714623c 100644 --- a/tests/tracing/test_integration_tests.py +++ b/tests/tracing/test_integration_tests.py @@ -81,6 +81,9 @@ def test_continue_from_headers( ) # child transaction, to prove that we can read 'sentry-trace' header data correctly + del headers[ + "traceparent" + ] # remove the traceparent header to simulate a missing W3C header child_transaction = Transaction.continue_from_headers(headers, name="WRONG") assert child_transaction is not None assert child_transaction.parent_sampled == parent_sampled @@ -147,7 +150,7 @@ def test_continue_from_headers( assert message_payload["message"] == "hello" -@pytest.mark.parametrize("parent_sampled", [True, False, None]) +@pytest.mark.parametrize("parent_sampled", [True, False]) @pytest.mark.parametrize("sample_rate", [0.0, 1.0]) def test_continue_from_w3c_headers( sentry_init, capture_envelopes, parent_sampled, sample_rate @@ -176,6 +179,9 @@ def test_continue_from_w3c_headers( ) # child transaction, to prove that we can read 'sentry-trace' header data correctly + del headers[ + "sentry-trace" + ] # remove the sentry-trace header to simulate a missing Sentry Trace header child_transaction = Transaction.continue_from_headers(headers, name="WRONG") assert child_transaction is not None assert child_transaction.parent_sampled == parent_sampled From ad1cde3d34cd8728bfe77bc2da9965b898b22032 Mon Sep 17 00:00:00 2001 From: hangy Date: Wed, 9 Apr 2025 19:53:09 +0200 Subject: [PATCH 05/12] refactor: Use bitmask to check if W3C trace-flags have the "sampled" bit set --- sentry_sdk/tracing_utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index aaab4ecef4..2faeda9a5f 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -53,7 +53,7 @@ "[0-9]{2}?" # version "-?([0-9a-f]{32})?" # trace_id "-?([0-9a-f]{16})?" # span_id - "-?([01]{2})?" # trace-flags + "-?([0-9]{2})?" # trace-flags "[ \t]*$" # whitespace ) @@ -369,7 +369,8 @@ def extract_w3c_traceparent_data(header): if parent_span_id: parent_span_id = "{:016x}".format(int(parent_span_id, 16)) if trace_flags: - parent_sampled = trace_flags == "01" + trace_flags_byte = int(trace_flags, 16) + parent_sampled = (trace_flags_byte & 0x01) == 1 return { "trace_id": trace_id, From 395e3e5646a909d232a6f22061276f5cb91ec92d Mon Sep 17 00:00:00 2001 From: hangy Date: Fri, 11 Apr 2025 19:51:45 +0200 Subject: [PATCH 06/12] test(tracing): enhance tests for W3C traceparent header handling across integrations --- tests/integrations/asgi/test_asgi.py | 24 +++- .../aws_lambda/test_aws_lambda.py | 79 +++++++++++ tests/integrations/gcp/test_gcp.py | 130 ++++++++++++++++-- tests/integrations/grpc/test_grpc.py | 47 +++++++ tests/integrations/grpc/test_grpc_aio.py | 43 ++++++ tests/integrations/httpx/test_httpx.py | 10 ++ .../opentelemetry/test_propagator.py | 45 ++++++ tests/integrations/stdlib/test_httplib.py | 14 ++ tests/integrations/tornado/test_tornado.py | 50 ++++++- 9 files changed, 426 insertions(+), 16 deletions(-) diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index f95ea14d01..f33e63578f 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -297,11 +297,21 @@ async def test_trace_from_headers_if_performance_enabled( trace_id = "582b43a4192642f0b136d5159a501701" sentry_trace_header = "{}-{}-{}".format(trace_id, "6e8f22c393e68f19", 1) + w3c_trace_header = "00-082b43a4192642f0b136d5159a501701-6e8f22c393e68f19-01" + + # If both sentry-trace and traceparent headers are present, sentry-trace takes precedence. + # See: https://github.com/getsentry/team-sdks/issues/41 with pytest.raises(ZeroDivisionError): async with TestClient(app) as client: events = capture_events() - await client.get("/", headers={"sentry-trace": sentry_trace_header}) + await client.get( + "/", + headers={ + "sentry-trace": sentry_trace_header, + "traceparent": w3c_trace_header, + }, + ) msg_event, error_event, transaction_event = events @@ -330,11 +340,21 @@ async def test_trace_from_headers_if_performance_disabled( trace_id = "582b43a4192642f0b136d5159a501701" sentry_trace_header = "{}-{}-{}".format(trace_id, "6e8f22c393e68f19", 1) + w3c_trace_header = "00-082b43a4192642f0b136d5159a501701-6e8f22c393e68f19-01" + + # If both sentry-trace and traceparent headers are present, sentry-trace takes precedence. + # See: https://github.com/getsentry/team-sdks/issues/41 with pytest.raises(ZeroDivisionError): async with TestClient(app) as client: events = capture_events() - await client.get("/", headers={"sentry-trace": sentry_trace_header}) + await client.get( + "/", + headers={ + "sentry-trace": sentry_trace_header, + "traceparent": w3c_trace_header, + }, + ) msg_event, error_event = events diff --git a/tests/integrations/aws_lambda/test_aws_lambda.py b/tests/integrations/aws_lambda/test_aws_lambda.py index 85da7e0b14..511c857dab 100644 --- a/tests/integrations/aws_lambda/test_aws_lambda.py +++ b/tests/integrations/aws_lambda/test_aws_lambda.py @@ -386,9 +386,41 @@ def test_trace_continuation(lambda_client, test_environment): # We simulate here AWS Api Gateway's behavior of passing HTTP headers # as the `headers` dict in the event passed to the Lambda function. + # If both sentry-trace and traceparent headers are present, sentry-trace takes precedence. + # See: https://github.com/getsentry/team-sdks/issues/41 payload = { "headers": { "sentry-trace": sentry_trace_header, + "traceparent": "00-071a43a4192642f0b136d5159a501701-6e8f22c393e68f19-01", + } + } + + lambda_client.invoke( + FunctionName="BasicException", + Payload=json.dumps(payload), + ) + envelopes = test_environment["server"].envelopes + + (error_event, transaction_event) = envelopes + + assert ( + error_event["contexts"]["trace"]["trace_id"] + == transaction_event["contexts"]["trace"]["trace_id"] + == "471a43a4192642f0b136d5159a501701" + ) + + +def test_trace_continuation_w3c_traceparent(lambda_client, test_environment): + trace_id = "471a43a4192642f0b136d5159a501701" + parent_span_id = "6e8f22c393e68f19" + parent_sampled = "01" + w3c_trace_header = "00-{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) + + # We simulate here AWS Api Gateway's behavior of passing HTTP headers + # as the `headers` dict in the event passed to the Lambda function. + payload = { + "headers": { + "traceparent": w3c_trace_header, } } @@ -516,9 +548,12 @@ def test_error_has_existing_trace_context( # We simulate here AWS Api Gateway's behavior of passing HTTP headers # as the `headers` dict in the event passed to the Lambda function. + # If both sentry-trace and traceparent headers are present, sentry-trace takes precedence. + # See: https://github.com/getsentry/team-sdks/issues/41 payload = { "headers": { "sentry-trace": sentry_trace_header, + "traceparent": "00-071a43a4192642f0b136d5159a501701-6e8f22c393e68f19-01", } } @@ -548,3 +583,47 @@ def test_error_has_existing_trace_context( transaction_event["contexts"]["trace"]["trace_id"] == "471a43a4192642f0b136d5159a501701" ) + + +@pytest.mark.parametrize( + "lambda_function_name", + ["RaiseErrorPerformanceEnabled", "RaiseErrorPerformanceDisabled"], +) +def test_error_has_existing_w3c_trace_context( + lambda_client, test_environment, lambda_function_name +): + trace_id = "471a43a4192642f0b136d5159a501701" + parent_span_id = "6e8f22c393e68f19" + parent_sampled = "01" + w3c_trace_header = "00-{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) + + # We simulate here AWS Api Gateway's behavior of passing HTTP headers + # as the `headers` dict in the event passed to the Lambda function. + payload = {"headers": {"traceparent": w3c_trace_header}} + + lambda_client.invoke( + FunctionName=lambda_function_name, + Payload=json.dumps(payload), + ) + envelopes = test_environment["server"].envelopes + + if lambda_function_name == "RaiseErrorPerformanceEnabled": + (error_event, transaction_event) = envelopes + else: + (error_event,) = envelopes + transaction_event = None + + assert "trace" in error_event["contexts"] + assert "trace_id" in error_event["contexts"]["trace"] + assert ( + error_event["contexts"]["trace"]["trace_id"] + == "471a43a4192642f0b136d5159a501701" + ) + + if transaction_event: + assert "trace" in transaction_event["contexts"] + assert "trace_id" in transaction_event["contexts"]["trace"] + assert ( + transaction_event["contexts"]["trace"]["trace_id"] + == "471a43a4192642f0b136d5159a501701" + ) diff --git a/tests/integrations/gcp/test_gcp.py b/tests/integrations/gcp/test_gcp.py index 22d104c817..b01091535a 100644 --- a/tests/integrations/gcp/test_gcp.py +++ b/tests/integrations/gcp/test_gcp.py @@ -360,7 +360,7 @@ def _safe_is_equal(x, y): def test_error_has_new_trace_context_performance_enabled(run_cloud_function): """ - Check if an 'trace' context is added to errros and transactions when performance monitoring is enabled. + Check if an 'trace' context is added to errors and transactions when performance monitoring is enabled. """ envelope_items, _ = run_cloud_function( dedent( @@ -401,7 +401,7 @@ def cloud_function(functionhandler, event): def test_error_has_new_trace_context_performance_disabled(run_cloud_function): """ - Check if an 'trace' context is added to errros and transactions when performance monitoring is disabled. + Check if an 'trace' context is added to errors and transactions when performance monitoring is disabled. """ envelope_items, _ = run_cloud_function( dedent( @@ -439,13 +439,123 @@ def cloud_function(functionhandler, event): def test_error_has_existing_trace_context_performance_enabled(run_cloud_function): """ - Check if an 'trace' context is added to errros and transactions + Check if an 'trace' context is added to errors and transactions from the incoming 'sentry-trace' header when performance monitoring is enabled. """ trace_id = "471a43a4192642f0b136d5159a501701" parent_span_id = "6e8f22c393e68f19" parent_sampled = 1 sentry_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) + w3c_trace_header = "00-971a43a4192642f0b136d5159a501701-6e8f22c393e68f19-00" + + # If both sentry-trace and traceparent headers are present, sentry-trace takes precedence. + # See: https://github.com/getsentry/team-sdks/issues/41 + + envelope_items, _ = run_cloud_function( + dedent( + """ + functionhandler = None + + from collections import namedtuple + GCPEvent = namedtuple("GCPEvent", ["headers"]) + event = GCPEvent(headers={"sentry-trace": "%s", "traceparent": "%s"}) + + def cloud_function(functionhandler, event): + sentry_sdk.capture_message("hi") + x = 3/0 + return "3" + """ + % sentry_trace_header, + w3c_trace_header, + ) + + FUNCTIONS_PRELUDE + + dedent( + """ + init_sdk(traces_sample_rate=1.0) + gcp_functions.worker_v1.FunctionHandler.invoke_user_function(functionhandler, event) + """ + ) + ) + (msg_event, error_event, transaction_event) = envelope_items + + assert "trace" in msg_event["contexts"] + assert "trace_id" in msg_event["contexts"]["trace"] + + assert "trace" in error_event["contexts"] + assert "trace_id" in error_event["contexts"]["trace"] + + assert "trace" in transaction_event["contexts"] + assert "trace_id" in transaction_event["contexts"]["trace"] + + assert ( + msg_event["contexts"]["trace"]["trace_id"] + == error_event["contexts"]["trace"]["trace_id"] + == transaction_event["contexts"]["trace"]["trace_id"] + == "471a43a4192642f0b136d5159a501701" + ) + + +def test_error_has_existing_w3c_trace_context_performance_disabled(run_cloud_function): + """ + Check if an 'trace' context is added to errors and transactions + from the incoming 'traceparent' header when performance monitoring is disabled. + """ + trace_id = "471a43a4192642f0b136d5159a501701" + parent_span_id = "6e8f22c393e68f19" + parent_sampled = "01" + w3c_trace_header = "00-{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) + + # If both sentry-trace and traceparent headers are present, sentry-trace takes precedence. + # See: https://github.com/getsentry/team-sdks/issues/41 + + envelope_items, _ = run_cloud_function( + dedent( + """ + functionhandler = None + + from collections import namedtuple + GCPEvent = namedtuple("GCPEvent", ["headers"]) + event = GCPEvent(headers={"traceparent": "%s"}) + + def cloud_function(functionhandler, event): + sentry_sdk.capture_message("hi") + x = 3/0 + return "3" + """ + % w3c_trace_header + ) + + FUNCTIONS_PRELUDE + + dedent( + """ + init_sdk(traces_sample_rate=None), # this is the default, just added for clarity + gcp_functions.worker_v1.FunctionHandler.invoke_user_function(functionhandler, event) + """ + ) + ) + (msg_event, error_event) = envelope_items + + assert "trace" in msg_event["contexts"] + assert "trace_id" in msg_event["contexts"]["trace"] + + assert "trace" in error_event["contexts"] + assert "trace_id" in error_event["contexts"]["trace"] + + assert ( + msg_event["contexts"]["trace"]["trace_id"] + == error_event["contexts"]["trace"]["trace_id"] + == "471a43a4192642f0b136d5159a501701" + ) + + +def test_error_has_existing_w3c_trace_context_performance_enabled(run_cloud_function): + """ + Check if an 'trace' context is added to errors and transactions + from the incoming 'traceparent' header when performance monitoring is enabled. + """ + trace_id = "471a43a4192642f0b136d5159a501701" + parent_span_id = "6e8f22c393e68f19" + parent_sampled = "01" + w3c_trace_header = "00-{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) envelope_items, _ = run_cloud_function( dedent( @@ -454,14 +564,14 @@ def test_error_has_existing_trace_context_performance_enabled(run_cloud_function from collections import namedtuple GCPEvent = namedtuple("GCPEvent", ["headers"]) - event = GCPEvent(headers={"sentry-trace": "%s"}) + event = GCPEvent(headers={"traceparent": "%s"}) def cloud_function(functionhandler, event): sentry_sdk.capture_message("hi") x = 3/0 return "3" """ - % sentry_trace_header + % w3c_trace_header ) + FUNCTIONS_PRELUDE + dedent( @@ -492,13 +602,13 @@ def cloud_function(functionhandler, event): def test_error_has_existing_trace_context_performance_disabled(run_cloud_function): """ - Check if an 'trace' context is added to errros and transactions + Check if an 'trace' context is added to errors and transactions from the incoming 'sentry-trace' header when performance monitoring is disabled. """ trace_id = "471a43a4192642f0b136d5159a501701" parent_span_id = "6e8f22c393e68f19" - parent_sampled = 1 - sentry_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) + parent_sampled = "01" + w3c_trace_header = "00-{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) envelope_items, _ = run_cloud_function( dedent( @@ -507,14 +617,14 @@ def test_error_has_existing_trace_context_performance_disabled(run_cloud_functio from collections import namedtuple GCPEvent = namedtuple("GCPEvent", ["headers"]) - event = GCPEvent(headers={"sentry-trace": "%s"}) + event = GCPEvent(headers={"traceparent": "%s"}) def cloud_function(functionhandler, event): sentry_sdk.capture_message("hi") x = 3/0 return "3" """ - % sentry_trace_header + % w3c_trace_header ) + FUNCTIONS_PRELUDE + dedent( diff --git a/tests/integrations/grpc/test_grpc.py b/tests/integrations/grpc/test_grpc.py index 8d2698f411..164ba660a8 100644 --- a/tests/integrations/grpc/test_grpc.py +++ b/tests/integrations/grpc/test_grpc.py @@ -152,6 +152,53 @@ def test_grpc_server_continues_transaction(sentry_init, capture_events_forksafe) assert span["op"] == "test" +@pytest.mark.forked +def test_grpc_server_continues_transaction_from_w3c_traceparent( + sentry_init, capture_events_forksafe +): + sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) + events = capture_events_forksafe() + + server, channel = _set_up() + + # Use the provided channel + stub = gRPCTestServiceStub(channel) + + with start_transaction() as transaction: + metadata = ( + ( + "baggage", + "sentry-trace_id={trace_id},sentry-environment=test," + "sentry-transaction=test-transaction,sentry-sample_rate=1.0".format( + trace_id=transaction.trace_id + ), + ), + ( + "traceparent", + "00-{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=transaction.trace_id, + parent_span_id=transaction.span_id, + sampled="01", + ), + ), + ) + stub.TestServe(gRPCTestMessage(text="test"), metadata=metadata) + + _tear_down(server=server) + + events.write_file.close() + event = events.read_event() + span = event["spans"][0] + + assert event["type"] == "transaction" + assert event["transaction_info"] == { + "source": "custom", + } + assert event["contexts"]["trace"]["op"] == OP.GRPC_SERVER + assert event["contexts"]["trace"]["trace_id"] == transaction.trace_id + assert span["op"] == "test" + + @pytest.mark.forked def test_grpc_client_starts_span(sentry_init, capture_events_forksafe): sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) diff --git a/tests/integrations/grpc/test_grpc_aio.py b/tests/integrations/grpc/test_grpc_aio.py index 96e9a4dba8..5aad3fd79e 100644 --- a/tests/integrations/grpc/test_grpc_aio.py +++ b/tests/integrations/grpc/test_grpc_aio.py @@ -136,6 +136,49 @@ async def test_grpc_server_continues_transaction( assert span["op"] == "test" +@pytest.mark.asyncio +async def test_grpc_server_continues_transaction_from_w3c_traceparent( + grpc_server_and_channel, capture_events +): + _, channel = grpc_server_and_channel + events = capture_events() + + # Use the provided channel + stub = gRPCTestServiceStub(channel) + + with sentry_sdk.start_transaction() as transaction: + metadata = ( + ( + "baggage", + "sentry-trace_id={trace_id},sentry-environment=test," + "sentry-transaction=test-transaction,sentry-sample_rate=1.0".format( + trace_id=transaction.trace_id + ), + ), + ( + "traceparent", + "00-{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=transaction.trace_id, + parent_span_id=transaction.span_id, + sampled="01", + ), + ), + ) + + await stub.TestServe(gRPCTestMessage(text="test"), metadata=metadata) + + (event, _) = events + span = event["spans"][0] + + assert event["type"] == "transaction" + assert event["transaction_info"] == { + "source": "custom", + } + assert event["contexts"]["trace"]["op"] == OP.GRPC_SERVER + assert event["contexts"]["trace"]["trace_id"] == transaction.trace_id + assert span["op"] == "test" + + @pytest.mark.asyncio async def test_grpc_server_exception(grpc_server_and_channel, capture_events): _, channel = grpc_server_and_channel diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py index 5a35b68076..67f5330995 100644 --- a/tests/integrations/httpx/test_httpx.py +++ b/tests/integrations/httpx/test_httpx.py @@ -149,6 +149,13 @@ def test_outgoing_trace_headers(sentry_init, httpx_client, httpx_mock): parent_span_id=request_span.span_id, sampled=1, ) + assert response.request.headers[ + "traceparent" + ] == "00-{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=transaction.trace_id, + parent_span_id=request_span.span_id, + sampled="01", + ) @pytest.mark.parametrize( @@ -338,8 +345,10 @@ def test_option_trace_propagation_targets( if trace_propagated: assert "sentry-trace" in request_headers + assert "traceparent" in request_headers else: assert "sentry-trace" not in request_headers + assert "traceparent" not in request_headers def test_do_not_propagate_outside_transaction(sentry_init, httpx_mock): @@ -356,6 +365,7 @@ def test_do_not_propagate_outside_transaction(sentry_init, httpx_mock): request_headers = httpx_mock.get_request().headers assert "sentry-trace" not in request_headers + assert "traceparent" not in request_headers @pytest.mark.tests_internal_exceptions diff --git a/tests/integrations/opentelemetry/test_propagator.py b/tests/integrations/opentelemetry/test_propagator.py index d999b0bb2b..2ecccf9f1d 100644 --- a/tests/integrations/opentelemetry/test_propagator.py +++ b/tests/integrations/opentelemetry/test_propagator.py @@ -199,6 +199,51 @@ def test_inject_sentry_span_no_baggage(): ) +@pytest.mark.forked +def test_inject_w3c_span_no_baggage(): + """ + Inject a W3C span with no baggage. + """ + carrier = None + context = get_current() + setter = MagicMock() + setter.set = MagicMock() + + trace_id = "ec2da13ee483f41e7323f8c78ed8f4e4" + span_id = "4c6d5fcab571acb0" + + span_context = SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id, 16), + trace_flags=TraceFlags(TraceFlags.SAMPLED), + is_remote=True, + ) + span = MagicMock() + span.get_span_context.return_value = span_context + + sentry_span = MagicMock() + sentry_span.to_w3c_traceparent = mock.Mock( + return_value="00-ec2da13ee483f41e7323f8c78ed8f4e4-4c6d5fcab571acb0-01" + ) + sentry_span.containing_transaction.get_baggage = mock.Mock(return_value=None) + + span_processor = SentrySpanProcessor() + span_processor.otel_span_map[span_id] = sentry_span + + with mock.patch( + "sentry_sdk.integrations.opentelemetry.propagator.trace.get_current_span", + return_value=span, + ): + full_context = set_span_in_context(span, context) + SentryPropagator().inject(carrier, full_context, setter) + + setter.set.assert_called_once_with( + carrier, + "traceparent", + "00-ec2da13ee483f41e7323f8c78ed8f4e4-4c6d5fcab571acb0-01", + ) + + def test_inject_sentry_span_empty_baggage(): """ Inject a sentry span with no baggage. diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 908a22dc6c..fa7203ee37 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -215,7 +215,13 @@ def test_outgoing_trace_headers(sentry_init, monkeypatch): parent_span_id=request_span.span_id, sampled=1, ) + expected_w3c_trace = "00-{trace_id}-{parent_span_id}-{trace_flags}".format( + trace_id=transaction.trace_id, + parent_span_id=request_span.span_id, + trace_flags="01", + ) assert request_headers["sentry-trace"] == expected_sentry_trace + assert request_headers["traceparent"] == expected_w3c_trace expected_outgoing_baggage = ( "sentry-trace_id=771a43a4192642f0b136d5159a501700," @@ -255,7 +261,13 @@ def test_outgoing_trace_headers_head_sdk(sentry_init, monkeypatch): parent_span_id=request_span.span_id, sampled=1, ) + expected_w3c_trace = "00-{trace_id}-{parent_span_id}-{trace_flags}".format( + trace_id=transaction.trace_id, + parent_span_id=request_span.span_id, + trace_flags="01", + ) assert request_headers["sentry-trace"] == expected_sentry_trace + assert request_headers["traceparent"] == expected_w3c_trace expected_outgoing_baggage = ( "sentry-trace_id=%s," @@ -368,9 +380,11 @@ def test_option_trace_propagation_targets( if trace_propagated: assert "sentry-trace" in request_headers + assert "traceparent" in request_headers assert "baggage" in request_headers else: assert "sentry-trace" not in request_headers + assert "traceparent" not in request_headers assert "baggage" not in request_headers diff --git a/tests/integrations/tornado/test_tornado.py b/tests/integrations/tornado/test_tornado.py index 294f605f6a..77907f63b5 100644 --- a/tests/integrations/tornado/test_tornado.py +++ b/tests/integrations/tornado/test_tornado.py @@ -300,7 +300,7 @@ def test_error_has_new_trace_context_performance_enabled( tornado_testcase, sentry_init, capture_events ): """ - Check if an 'trace' context is added to errros and transactions when performance monitoring is enabled. + Check if an 'trace' context is added to errors and transactions when performance monitoring is enabled. """ sentry_init( integrations=[TornadoIntegration()], @@ -333,7 +333,7 @@ def test_error_has_new_trace_context_performance_disabled( tornado_testcase, sentry_init, capture_events ): """ - Check if an 'trace' context is added to errros and transactions when performance monitoring is disabled. + Check if an 'trace' context is added to errors and transactions when performance monitoring is disabled. """ sentry_init( integrations=[TornadoIntegration()], @@ -362,7 +362,7 @@ def test_error_has_existing_trace_context_performance_enabled( tornado_testcase, sentry_init, capture_events ): """ - Check if an 'trace' context is added to errros and transactions + Check if an 'trace' context is added to errors and transactions from the incoming 'sentry-trace' header when performance monitoring is enabled. """ sentry_init( @@ -400,11 +400,53 @@ def test_error_has_existing_trace_context_performance_enabled( ) +def test_error_has_existing_w3c_trace_context_performance_enabled( + tornado_testcase, sentry_init, capture_events +): + """ + Check if an 'trace' context is added to errors and transactions + from the incoming 'traceparent' header when performance monitoring is enabled. + """ + sentry_init( + integrations=[TornadoIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + trace_id = "d6a6892c89ff99522ab2041c4cd71a68" + parent_span_id = "6fac8cb0c8a904d0" + parent_sampled = "01" + w3c_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) + + headers = {"traceparent": w3c_trace_header} + + client = tornado_testcase(Application([(r"/hi", CrashingWithMessageHandler)])) + client.fetch("/hi", headers=headers) + + (msg_event, error_event, transaction_event) = events + + assert "trace" in msg_event["contexts"] + assert "trace_id" in msg_event["contexts"]["trace"] + + assert "trace" in error_event["contexts"] + assert "trace_id" in error_event["contexts"]["trace"] + + assert "trace" in transaction_event["contexts"] + assert "trace_id" in transaction_event["contexts"]["trace"] + + assert ( + msg_event["contexts"]["trace"]["trace_id"] + == error_event["contexts"]["trace"]["trace_id"] + == transaction_event["contexts"]["trace"]["trace_id"] + == "d6a6892c89ff99522ab2041c4cd71a68" + ) + + def test_error_has_existing_trace_context_performance_disabled( tornado_testcase, sentry_init, capture_events ): """ - Check if an 'trace' context is added to errros and transactions + Check if an 'trace' context is added to errors and transactions from the incoming 'sentry-trace' header when performance monitoring is disabled. """ sentry_init( From b5bf667eec3c99024ca31c5fc82518a555195da4 Mon Sep 17 00:00:00 2001 From: hangy Date: Fri, 11 Apr 2025 20:44:14 +0200 Subject: [PATCH 07/12] feat(tracing): add support for W3C traceparent header in SentryPropagator and enhance tests --- .../integrations/opentelemetry/propagator.py | 4 +- tests/integrations/asgi/test_asgi.py | 71 +++++++++++++++++++ .../opentelemetry/test_propagator.py | 6 +- tests/integrations/tornado/test_tornado.py | 2 +- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/integrations/opentelemetry/propagator.py b/sentry_sdk/integrations/opentelemetry/propagator.py index b84d582d6e..a19bbf6bad 100644 --- a/sentry_sdk/integrations/opentelemetry/propagator.py +++ b/sentry_sdk/integrations/opentelemetry/propagator.py @@ -28,6 +28,7 @@ from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, + W3C_TRACE_HEADER_NAME, ) from sentry_sdk.tracing_utils import Baggage, extract_sentrytrace_data @@ -103,6 +104,7 @@ def inject(self, carrier, context=None, setter=default_setter): return setter.set(carrier, SENTRY_TRACE_HEADER_NAME, sentry_span.to_traceparent()) + setter.set(carrier, W3C_TRACE_HEADER_NAME, sentry_span.to_w3c_traceparent()) if sentry_span.containing_transaction: baggage = sentry_span.containing_transaction.get_baggage() @@ -114,4 +116,4 @@ def inject(self, carrier, context=None, setter=default_setter): @property def fields(self): # type: () -> Set[str] - return {SENTRY_TRACE_HEADER_NAME, BAGGAGE_HEADER_NAME} + return {SENTRY_TRACE_HEADER_NAME, BAGGAGE_HEADER_NAME, W3C_TRACE_HEADER_NAME} diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index f33e63578f..38279f4bcc 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -367,6 +367,77 @@ async def test_trace_from_headers_if_performance_disabled( assert error_event["contexts"]["trace"]["trace_id"] == trace_id +@pytest.mark.asyncio +async def test_trace_from_w3c_headers_if_performance_enabled( + sentry_init, + asgi3_app_with_error_and_msg, + capture_events, +): + sentry_init(traces_sample_rate=1.0) + app = SentryAsgiMiddleware(asgi3_app_with_error_and_msg) + + trace_id = "582b43a4192642f0b136d5159a501701" + w3c_trace_header = "00-{}-{}-{}".format(trace_id, "6e8f22c393e68f19", "01") + + with pytest.raises(ZeroDivisionError): + async with TestClient(app) as client: + events = capture_events() + await client.get( + "/", + headers={ + "traceparent": w3c_trace_header, + }, + ) + + msg_event, error_event, transaction_event = events + + assert msg_event["contexts"]["trace"] + assert "trace_id" in msg_event["contexts"]["trace"] + + assert error_event["contexts"]["trace"] + assert "trace_id" in error_event["contexts"]["trace"] + + assert transaction_event["contexts"]["trace"] + assert "trace_id" in transaction_event["contexts"]["trace"] + + assert msg_event["contexts"]["trace"]["trace_id"] == trace_id + assert error_event["contexts"]["trace"]["trace_id"] == trace_id + assert transaction_event["contexts"]["trace"]["trace_id"] == trace_id + + +@pytest.mark.asyncio +async def test_trace_from_w3c_headers_if_performance_disabled( + sentry_init, + asgi3_app_with_error_and_msg, + capture_events, +): + sentry_init() + app = SentryAsgiMiddleware(asgi3_app_with_error_and_msg) + + trace_id = "582b43a4192642f0b136d5159a501701" + w3c_trace_header = "00-{}-{}-{}".format(trace_id, "6e8f22c393e68f19", "01") + + with pytest.raises(ZeroDivisionError): + async with TestClient(app) as client: + events = capture_events() + await client.get( + "/", + headers={ + "traceparent": w3c_trace_header, + }, + ) + + msg_event, error_event = events + + assert msg_event["contexts"]["trace"] + assert "trace_id" in msg_event["contexts"]["trace"] + assert msg_event["contexts"]["trace"]["trace_id"] == trace_id + + assert error_event["contexts"]["trace"] + assert "trace_id" in error_event["contexts"]["trace"] + assert error_event["contexts"]["trace"]["trace_id"] == trace_id + + @pytest.mark.asyncio async def test_websocket(sentry_init, asgi3_ws_app, capture_events, request): sentry_init(send_default_pii=True) diff --git a/tests/integrations/opentelemetry/test_propagator.py b/tests/integrations/opentelemetry/test_propagator.py index 2ecccf9f1d..17b803ccdc 100644 --- a/tests/integrations/opentelemetry/test_propagator.py +++ b/tests/integrations/opentelemetry/test_propagator.py @@ -192,7 +192,7 @@ def test_inject_sentry_span_no_baggage(): full_context = set_span_in_context(span, context) SentryPropagator().inject(carrier, full_context, setter) - setter.set.assert_called_once_with( + setter.set.assert_any_call( carrier, "sentry-trace", "1234567890abcdef1234567890abcdef-1234567890abcdef-1", @@ -237,7 +237,7 @@ def test_inject_w3c_span_no_baggage(): full_context = set_span_in_context(span, context) SentryPropagator().inject(carrier, full_context, setter) - setter.set.assert_called_once_with( + setter.set.assert_any_call( carrier, "traceparent", "00-ec2da13ee483f41e7323f8c78ed8f4e4-4c6d5fcab571acb0-01", @@ -281,7 +281,7 @@ def test_inject_sentry_span_empty_baggage(): full_context = set_span_in_context(span, context) SentryPropagator().inject(carrier, full_context, setter) - setter.set.assert_called_once_with( + setter.set.assert_any_call( carrier, "sentry-trace", "1234567890abcdef1234567890abcdef-1234567890abcdef-1", diff --git a/tests/integrations/tornado/test_tornado.py b/tests/integrations/tornado/test_tornado.py index 77907f63b5..deaa153ff8 100644 --- a/tests/integrations/tornado/test_tornado.py +++ b/tests/integrations/tornado/test_tornado.py @@ -416,7 +416,7 @@ def test_error_has_existing_w3c_trace_context_performance_enabled( trace_id = "d6a6892c89ff99522ab2041c4cd71a68" parent_span_id = "6fac8cb0c8a904d0" parent_sampled = "01" - w3c_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) + w3c_trace_header = "00-{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) headers = {"traceparent": w3c_trace_header} From 1f5e6e27ae2f76c966ed5e1458291ea5b3f30072 Mon Sep 17 00:00:00 2001 From: hangy Date: Fri, 11 Apr 2025 21:22:42 +0200 Subject: [PATCH 08/12] test(aiohttp): add traceparent header to basic test assertions --- tests/integrations/aiohttp/test_aiohttp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index ef7c04e90a..e641d0220f 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -61,6 +61,7 @@ async def hello(request): "User-Agent": request["headers"]["User-Agent"], "baggage": mock.ANY, "sentry-trace": mock.ANY, + "traceparent": mock.ANY, } From dd0e45a116c90b8a399b978da6124e9c46c14b48 Mon Sep 17 00:00:00 2001 From: hangy Date: Fri, 11 Apr 2025 21:26:54 +0200 Subject: [PATCH 09/12] fix(tests): correct formatting of trace headers in error handling test --- tests/integrations/gcp/test_gcp.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/integrations/gcp/test_gcp.py b/tests/integrations/gcp/test_gcp.py index b01091535a..07501bbdf1 100644 --- a/tests/integrations/gcp/test_gcp.py +++ b/tests/integrations/gcp/test_gcp.py @@ -465,8 +465,7 @@ def cloud_function(functionhandler, event): x = 3/0 return "3" """ - % sentry_trace_header, - w3c_trace_header, + % (sentry_trace_header, w3c_trace_header) ) + FUNCTIONS_PRELUDE + dedent( From 415e9e798e9c196cb2708a2c5055121bbdab2835 Mon Sep 17 00:00:00 2001 From: hangy Date: Fri, 11 Apr 2025 21:55:58 +0200 Subject: [PATCH 10/12] test(celery): add traceparent header assertions in task header tests --- tests/integrations/celery/test_celery.py | 7 +++++++ .../celery/test_update_celery_task_headers.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/tests/integrations/celery/test_celery.py b/tests/integrations/celery/test_celery.py index 8c794bd5ff..5cb1982339 100644 --- a/tests/integrations/celery/test_celery.py +++ b/tests/integrations/celery/test_celery.py @@ -496,6 +496,7 @@ def dummy_task(self, x, y): expected_headers = sentry_crons_setup.copy() # Newly added headers expected_headers["sentry-trace"] = mock.ANY + expected_headers["traceparent"] = mock.ANY expected_headers["baggage"] = mock.ANY expected_headers["sentry-task-enqueued-time"] = mock.ANY @@ -569,6 +570,7 @@ def test_apply_async_manually_span(sentry_init): def dummy_function(*args, **kwargs): headers = kwargs.get("headers") assert "sentry-trace" in headers + assert "traceparent" in headers assert "baggage" in headers wrapped = _wrap_task_run(dummy_function) @@ -813,11 +815,13 @@ def test_send_task_wrapped( assert set(kwargs["headers"].keys()) == { "sentry-task-enqueued-time", "sentry-trace", + "traceparent", "baggage", "headers", } assert set(kwargs["headers"]["headers"].keys()) == { "sentry-trace", + "traceparent", "baggage", "sentry-task-enqueued-time", } @@ -825,6 +829,9 @@ def test_send_task_wrapped( kwargs["headers"]["sentry-trace"] == kwargs["headers"]["headers"]["sentry-trace"] ) + assert ( + kwargs["headers"]["traceparent"] == kwargs["headers"]["headers"]["traceparent"] + ) (event,) = events # We should have exactly one event (the transaction) assert event["type"] == "transaction" diff --git a/tests/integrations/celery/test_update_celery_task_headers.py b/tests/integrations/celery/test_update_celery_task_headers.py index 705c00de58..f018a43906 100644 --- a/tests/integrations/celery/test_update_celery_task_headers.py +++ b/tests/integrations/celery/test_update_celery_task_headers.py @@ -83,6 +83,10 @@ def test_span_with_transaction(sentry_init): assert outgoing_headers["sentry-trace"] == span.to_traceparent() assert outgoing_headers["headers"]["sentry-trace"] == span.to_traceparent() + assert outgoing_headers["traceparent"] == span.to_w3c_traceparent() + assert ( + outgoing_headers["headers"]["traceparent"] == span.to_w3c_traceparent() + ) assert outgoing_headers["baggage"] == transaction.get_baggage().serialize() assert ( outgoing_headers["headers"]["baggage"] @@ -103,6 +107,10 @@ def test_span_with_transaction_custom_headers(sentry_init): assert outgoing_headers["sentry-trace"] == span.to_traceparent() assert outgoing_headers["headers"]["sentry-trace"] == span.to_traceparent() + assert outgoing_headers["traceparent"] == span.to_w3c_traceparent() + assert ( + outgoing_headers["headers"]["traceparent"] == span.to_w3c_traceparent() + ) incoming_baggage = Baggage.from_incoming_header(headers["baggage"]) combined_baggage = copy(transaction.get_baggage()) @@ -145,6 +153,8 @@ def test_celery_trace_propagation_default(sentry_init, monitor_beat_tasks): assert outgoing_headers["sentry-trace"] == scope.get_traceparent() assert outgoing_headers["headers"]["sentry-trace"] == scope.get_traceparent() + assert outgoing_headers["traceparent"] == scope._get_w3c_traceparent() + assert outgoing_headers["headers"]["traceparent"] == scope._get_w3c_traceparent() assert outgoing_headers["baggage"] == scope.get_baggage().serialize() assert outgoing_headers["headers"]["baggage"] == scope.get_baggage().serialize() @@ -181,6 +191,8 @@ def test_celery_trace_propagation_traces_sample_rate( assert outgoing_headers["sentry-trace"] == scope.get_traceparent() assert outgoing_headers["headers"]["sentry-trace"] == scope.get_traceparent() + assert outgoing_headers["traceparent"] == scope._get_w3c_traceparent() + assert outgoing_headers["headers"]["traceparent"] == scope._get_w3c_traceparent() assert outgoing_headers["baggage"] == scope.get_baggage().serialize() assert outgoing_headers["headers"]["baggage"] == scope.get_baggage().serialize() @@ -217,6 +229,8 @@ def test_celery_trace_propagation_enable_tracing( assert outgoing_headers["sentry-trace"] == scope.get_traceparent() assert outgoing_headers["headers"]["sentry-trace"] == scope.get_traceparent() + assert outgoing_headers["traceparent"] == scope._get_w3c_traceparent() + assert outgoing_headers["headers"]["traceparent"] == scope._get_w3c_traceparent() assert outgoing_headers["baggage"] == scope.get_baggage().serialize() assert outgoing_headers["headers"]["baggage"] == scope.get_baggage().serialize() From 68cb15bbf6623a3880db4a9667e7a910a0396e91 Mon Sep 17 00:00:00 2001 From: hangy Date: Tue, 22 Apr 2025 21:09:30 +0200 Subject: [PATCH 11/12] test(propagator): Add test for fields returning known headers --- tests/integrations/opentelemetry/test_propagator.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/integrations/opentelemetry/test_propagator.py b/tests/integrations/opentelemetry/test_propagator.py index 17b803ccdc..2ae48517b4 100644 --- a/tests/integrations/opentelemetry/test_propagator.py +++ b/tests/integrations/opentelemetry/test_propagator.py @@ -343,3 +343,16 @@ def test_inject_sentry_span_baggage(): "baggage", baggage.serialize(), ) + + +def test_fields_returns_known_headers(): + """ + Test that the fields property returns all expected headers. + """ + propagator = SentryPropagator() + expected_fields = [ + "sentry-trace", + "baggage", + "traceparent", + ] + assert not propagator.fields ^ set(expected_fields) From 42016b8c54ceed45c2e138b727a7a47bd21a0460 Mon Sep 17 00:00:00 2001 From: hangy Date: Tue, 22 Apr 2025 23:01:57 +0200 Subject: [PATCH 12/12] test(traceparent): Add test for invalid W3C traceparent headers --- tests/tracing/test_http_headers.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/tracing/test_http_headers.py b/tests/tracing/test_http_headers.py index eea4eecda3..43128c7e30 100644 --- a/tests/tracing/test_http_headers.py +++ b/tests/tracing/test_http_headers.py @@ -76,6 +76,28 @@ def test_w3c_traceparent_extraction(sampling_decision): } +@pytest.mark.parametrize( + "traceparent_header", + [ + None, # No header + "", # Empty header + "0-d00afb0f1514f9337a4a921c514955db-903c8c4987adea4b-01", # No regex match because of invalid version + "00-d00afb0f1514f9337a4a921c514955d-903c8c4987adea4b", # No regex match because of invalid trace_id + "00-d00afb0f1514f9337a4a921c514955db-903c8c4987adea4", # No regex match because of invalid trace_id + "01-d00afb0f1514f9337a4a921c514955db-903c8c4987adea4b", # Invalid version + ], +) +def test_w3c_traceparent_extraction_invalid_headers_returns_none(traceparent_header): + """ + Test that extracting the W3C traceparent header returns None for invalid or unsupported headers. + """ + extracted_header = extract_w3c_traceparent_data(traceparent_header) + if extracted_header is not None: + pytest.fail( + f"Extracted traceparent from W3C header {traceparent_header} error returned {extracted_header} but should have been None." + ) + + def test_iter_headers(monkeypatch): monkeypatch.setattr( Transaction,