Skip to content

Commit 6db44a9

Browse files
authored
Baggage creation for head of trace (#1589)
1 parent f932402 commit 6db44a9

File tree

6 files changed

+220
-19
lines changed

6 files changed

+220
-19
lines changed

sentry_sdk/hub.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,19 @@ def iter_trace_propagation_headers(self, span=None):
717717
for header in span.iter_headers():
718718
yield header
719719

720+
def trace_propagation_meta(self, span=None):
721+
# type: (Optional[Span]) -> str
722+
"""
723+
Return meta tags which should be injected into the HTML template
724+
to allow propagation of trace data.
725+
"""
726+
meta = ""
727+
728+
for name, content in self.iter_trace_propagation_headers(span):
729+
meta += '<meta name="%s" content="%s">' % (name, content)
730+
731+
return meta
732+
720733

721734
GLOBAL_HUB = Hub()
722735
_local.set(GLOBAL_HUB)

sentry_sdk/tracing.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@
3535
TRANSACTION_SOURCE_COMPONENT = "component"
3636
TRANSACTION_SOURCE_TASK = "task"
3737

38+
# These are typically high cardinality and the server hates them
39+
LOW_QUALITY_TRANSACTION_SOURCES = [
40+
TRANSACTION_SOURCE_URL,
41+
]
42+
3843
SOURCE_FOR_STYLE = {
3944
"endpoint": TRANSACTION_SOURCE_COMPONENT,
4045
"function_name": TRANSACTION_SOURCE_COMPONENT,
@@ -281,6 +286,10 @@ def continue_from_headers(
281286

282287
if sentrytrace_kwargs is not None:
283288
kwargs.update(sentrytrace_kwargs)
289+
290+
# If there's an incoming sentry-trace but no incoming baggage header,
291+
# for instance in traces coming from older SDKs,
292+
# baggage will be empty and immutable and won't be populated as head SDK.
284293
baggage.freeze()
285294

286295
kwargs.update(extract_tracestate_data(headers.get("tracestate")))
@@ -309,8 +318,8 @@ def iter_headers(self):
309318
if tracestate:
310319
yield "tracestate", tracestate
311320

312-
if self.containing_transaction and self.containing_transaction._baggage:
313-
baggage = self.containing_transaction._baggage.serialize()
321+
if self.containing_transaction:
322+
baggage = self.containing_transaction.get_baggage().serialize()
314323
if baggage:
315324
yield "baggage", baggage
316325

@@ -513,11 +522,10 @@ def get_trace_context(self):
513522
if sentry_tracestate:
514523
rv["tracestate"] = sentry_tracestate
515524

516-
# TODO-neel populate fresh if head SDK
517-
if self.containing_transaction and self.containing_transaction._baggage:
525+
if self.containing_transaction:
518526
rv[
519527
"dynamic_sampling_context"
520-
] = self.containing_transaction._baggage.dynamic_sampling_context()
528+
] = self.containing_transaction.get_baggage().dynamic_sampling_context()
521529

522530
return rv
523531

@@ -527,6 +535,8 @@ class Transaction(Span):
527535
"name",
528536
"source",
529537
"parent_sampled",
538+
# used to create baggage value for head SDKs in dynamic sampling
539+
"sample_rate",
530540
# the sentry portion of the `tracestate` header used to transmit
531541
# correlation context for server-side dynamic sampling, of the form
532542
# `sentry=xxxxx`, where `xxxxx` is the base64-encoded json of the
@@ -562,6 +572,7 @@ def __init__(
562572
Span.__init__(self, **kwargs)
563573
self.name = name
564574
self.source = source
575+
self.sample_rate = None # type: Optional[float]
565576
self.parent_sampled = parent_sampled
566577
# if tracestate isn't inherited and set here, it will get set lazily,
567578
# either the first time an outgoing request needs it for a header or the
@@ -570,7 +581,7 @@ def __init__(
570581
self._third_party_tracestate = third_party_tracestate
571582
self._measurements = {} # type: Dict[str, Any]
572583
self._profile = None # type: Optional[Sampler]
573-
self._baggage = baggage
584+
self._baggage = baggage # type: Optional[Baggage]
574585

575586
def __repr__(self):
576587
# type: () -> str
@@ -708,6 +719,17 @@ def to_json(self):
708719

709720
return rv
710721

722+
def get_baggage(self):
723+
# type: () -> Baggage
724+
"""
725+
The first time a new baggage with sentry items is made,
726+
it will be frozen.
727+
"""
728+
if not self._baggage or self._baggage.mutable:
729+
self._baggage = Baggage.populate_from_transaction(self)
730+
731+
return self._baggage
732+
711733
def _set_initial_sampling_decision(self, sampling_context):
712734
# type: (SamplingContext) -> None
713735
"""
@@ -745,6 +767,7 @@ def _set_initial_sampling_decision(self, sampling_context):
745767
# if the user has forced a sampling decision by passing a `sampled`
746768
# value when starting the transaction, go with that
747769
if self.sampled is not None:
770+
self.sample_rate = float(self.sampled)
748771
return
749772

750773
# we would have bailed already if neither `traces_sampler` nor
@@ -773,6 +796,8 @@ def _set_initial_sampling_decision(self, sampling_context):
773796
self.sampled = False
774797
return
775798

799+
self.sample_rate = float(sample_rate)
800+
776801
# if the function returned 0 (or false), or if `traces_sample_rate` is
777802
# 0, it's a sign the transaction should be dropped
778803
if not sample_rate:

sentry_sdk/tracing_utils.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,54 @@ def from_incoming_header(cls, header):
470470

471471
return Baggage(sentry_items, third_party_items, mutable)
472472

473+
@classmethod
474+
def populate_from_transaction(cls, transaction):
475+
# type: (Transaction) -> Baggage
476+
"""
477+
Populate fresh baggage entry with sentry_items and make it immutable
478+
if this is the head SDK which originates traces.
479+
"""
480+
hub = transaction.hub or sentry_sdk.Hub.current
481+
client = hub.client
482+
sentry_items = {} # type: Dict[str, str]
483+
484+
if not client:
485+
return Baggage(sentry_items)
486+
487+
options = client.options or {}
488+
user = (hub.scope and hub.scope._user) or {}
489+
490+
sentry_items["trace_id"] = transaction.trace_id
491+
492+
if options.get("environment"):
493+
sentry_items["environment"] = options["environment"]
494+
495+
if options.get("release"):
496+
sentry_items["release"] = options["release"]
497+
498+
if options.get("dsn"):
499+
sentry_items["public_key"] = Dsn(options["dsn"]).public_key
500+
501+
if (
502+
transaction.name
503+
and transaction.source not in LOW_QUALITY_TRANSACTION_SOURCES
504+
):
505+
sentry_items["transaction"] = transaction.name
506+
507+
if user.get("segment"):
508+
sentry_items["user_segment"] = user["segment"]
509+
510+
if transaction.sample_rate is not None:
511+
sentry_items["sample_rate"] = str(transaction.sample_rate)
512+
513+
# there's an existing baggage but it was mutable,
514+
# which is why we are creating this new baggage.
515+
# However, if by chance the user put some sentry items in there, give them precedence.
516+
if transaction._baggage and transaction._baggage.sentry_items:
517+
sentry_items.update(transaction._baggage.sentry_items)
518+
519+
return Baggage(sentry_items, mutable=False)
520+
473521
def freeze(self):
474522
# type: () -> None
475523
self.mutable = False
@@ -500,6 +548,7 @@ def serialize(self, include_third_party=False):
500548

501549

502550
# Circular imports
551+
from sentry_sdk.tracing import LOW_QUALITY_TRANSACTION_SOURCES
503552

504553
if MYPY:
505-
from sentry_sdk.tracing import Span
554+
from sentry_sdk.tracing import Span, Transaction

tests/integrations/sqlalchemy/test_sqlalchemy.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -191,14 +191,6 @@ def processor(event, hint):
191191
# Some spans are discarded.
192192
assert len(event["spans"]) == 1000
193193

194-
# Some spans have their descriptions truncated. Because the test always
195-
# generates the same amount of descriptions and truncation is deterministic,
196-
# the number here should never change across test runs.
197-
#
198-
# Which exact span descriptions are truncated depends on the span durations
199-
# of each SQL query and is non-deterministic.
200-
assert len(event["_meta"]["spans"]) == 537
201-
202194
for i, span in enumerate(event["spans"]):
203195
description = span["description"]
204196

tests/integrations/stdlib/test_httplib.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import platform
22
import sys
3-
3+
import random
44
import pytest
55

66
try:
@@ -122,9 +122,7 @@ def test_httplib_misuse(sentry_init, capture_events, request):
122122
}
123123

124124

125-
def test_outgoing_trace_headers(
126-
sentry_init, monkeypatch, StringContaining # noqa: N803
127-
):
125+
def test_outgoing_trace_headers(sentry_init, monkeypatch):
128126
# HTTPSConnection.send is passed a string containing (among other things)
129127
# the headers on the request. Mock it so we can check the headers, and also
130128
# so it doesn't try to actually talk to the internet.
@@ -176,3 +174,46 @@ def test_outgoing_trace_headers(
176174
assert sorted(request_headers["baggage"].split(",")) == sorted(
177175
expected_outgoing_baggage_items
178176
)
177+
178+
179+
def test_outgoing_trace_headers_head_sdk(sentry_init, monkeypatch):
180+
# HTTPSConnection.send is passed a string containing (among other things)
181+
# the headers on the request. Mock it so we can check the headers, and also
182+
# so it doesn't try to actually talk to the internet.
183+
mock_send = mock.Mock()
184+
monkeypatch.setattr(HTTPSConnection, "send", mock_send)
185+
186+
# make sure transaction is always sampled
187+
monkeypatch.setattr(random, "random", lambda: 0.1)
188+
189+
sentry_init(traces_sample_rate=0.5, release="foo")
190+
transaction = Transaction.continue_from_headers({})
191+
192+
with start_transaction(transaction=transaction, name="Head SDK tx") as transaction:
193+
HTTPSConnection("www.squirrelchasers.com").request("GET", "/top-chasers")
194+
195+
(request_str,) = mock_send.call_args[0]
196+
request_headers = {}
197+
for line in request_str.decode("utf-8").split("\r\n")[1:]:
198+
if line:
199+
key, val = line.split(": ")
200+
request_headers[key] = val
201+
202+
request_span = transaction._span_recorder.spans[-1]
203+
expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format(
204+
trace_id=transaction.trace_id,
205+
parent_span_id=request_span.span_id,
206+
sampled=1,
207+
)
208+
assert request_headers["sentry-trace"] == expected_sentry_trace
209+
210+
expected_outgoing_baggage_items = [
211+
"sentry-trace_id=%s" % transaction.trace_id,
212+
"sentry-sample_rate=0.5",
213+
"sentry-release=foo",
214+
"sentry-environment=production",
215+
]
216+
217+
assert sorted(request_headers["baggage"].split(",")) == sorted(
218+
expected_outgoing_baggage_items
219+
)

tests/tracing/test_integration_tests.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# coding: utf-8
22
import weakref
33
import gc
4+
import re
45
import pytest
6+
import random
57

68
from sentry_sdk import (
79
capture_message,
@@ -142,6 +144,61 @@ def test_continue_from_headers(sentry_init, capture_envelopes, sampled, sample_r
142144
assert message_payload["message"] == "hello"
143145

144146

147+
@pytest.mark.parametrize("sample_rate", [0.5, 1.0])
148+
def test_dynamic_sampling_head_sdk_creates_dsc(
149+
sentry_init, capture_envelopes, sample_rate, monkeypatch
150+
):
151+
sentry_init(traces_sample_rate=sample_rate, release="foo")
152+
envelopes = capture_envelopes()
153+
154+
# make sure transaction is sampled for both cases
155+
monkeypatch.setattr(random, "random", lambda: 0.1)
156+
157+
transaction = Transaction.continue_from_headers({}, name="Head SDK tx")
158+
159+
# will create empty mutable baggage
160+
baggage = transaction._baggage
161+
assert baggage
162+
assert baggage.mutable
163+
assert baggage.sentry_items == {}
164+
assert baggage.third_party_items == ""
165+
166+
with start_transaction(transaction):
167+
with start_span(op="foo", description="foodesc"):
168+
pass
169+
170+
# finish will create a new baggage entry
171+
baggage = transaction._baggage
172+
trace_id = transaction.trace_id
173+
174+
assert baggage
175+
assert not baggage.mutable
176+
assert baggage.third_party_items == ""
177+
assert baggage.sentry_items == {
178+
"environment": "production",
179+
"release": "foo",
180+
"sample_rate": str(sample_rate),
181+
"transaction": "Head SDK tx",
182+
"trace_id": trace_id,
183+
}
184+
185+
expected_baggage = (
186+
"sentry-environment=production,sentry-release=foo,sentry-sample_rate=%s,sentry-transaction=Head%%20SDK%%20tx,sentry-trace_id=%s"
187+
% (sample_rate, trace_id)
188+
)
189+
assert sorted(baggage.serialize().split(",")) == sorted(expected_baggage.split(","))
190+
191+
(envelope,) = envelopes
192+
assert envelope.headers["trace"] == baggage.dynamic_sampling_context()
193+
assert envelope.headers["trace"] == {
194+
"environment": "production",
195+
"release": "foo",
196+
"sample_rate": str(sample_rate),
197+
"transaction": "Head SDK tx",
198+
"trace_id": trace_id,
199+
}
200+
201+
145202
@pytest.mark.parametrize(
146203
"args,expected_refcount",
147204
[({"traces_sample_rate": 1.0}, 100), ({"traces_sample_rate": 0.0}, 0)],
@@ -201,3 +258,27 @@ def capture_event(self, event):
201258
pass
202259

203260
assert len(events) == 1
261+
262+
263+
def test_trace_propagation_meta_head_sdk(sentry_init):
264+
sentry_init(traces_sample_rate=1.0, release="foo")
265+
266+
transaction = Transaction.continue_from_headers({}, name="Head SDK tx")
267+
meta = None
268+
span = None
269+
270+
with start_transaction(transaction):
271+
with start_span(op="foo", description="foodesc") as current_span:
272+
span = current_span
273+
meta = Hub.current.trace_propagation_meta()
274+
275+
ind = meta.find(">") + 1
276+
sentry_trace, baggage = meta[:ind], meta[ind:]
277+
278+
assert 'meta name="sentry-trace"' in sentry_trace
279+
sentry_trace_content = re.findall('content="([^"]*)"', sentry_trace)[0]
280+
assert sentry_trace_content == span.to_traceparent()
281+
282+
assert 'meta name="baggage"' in baggage
283+
baggage_content = re.findall('content="([^"]*)"', baggage)[0]
284+
assert baggage_content == transaction.get_baggage().serialize()

0 commit comments

Comments
 (0)