Skip to content

Commit 90d56bd

Browse files
committed
feat(baggage): implement serialization with third-party items respecting W3C limits
1 parent 51dfec5 commit 90d56bd

File tree

4 files changed

+244
-1
lines changed

4 files changed

+244
-1
lines changed

sentry-ruby/lib/sentry/baggage.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ module Sentry
77
class Baggage
88
SENTRY_PREFIX = "sentry-"
99
SENTRY_PREFIX_REGEX = /^sentry-/
10+
MAX_MEMBER_COUNT = 64
11+
MAX_BAGGAGE_BYTES = 8192
1012

1113
# @return [Hash]
1214
attr_reader :items
@@ -66,5 +68,58 @@ def serialize
6668
items = @items.map { |k, v| "#{SENTRY_PREFIX}#{CGI.escape(k)}=#{CGI.escape(v)}" }
6769
items.join(",")
6870
end
71+
72+
# Serialize sentry baggage items combined with third-party items from an existing header,
73+
# respecting W3C limits (max 64 members, max 8192 bytes).
74+
# Drops third-party items first when limits are exceeded, then sentry items if still over.
75+
#
76+
# @param sentry_items [Hash] Sentry baggage items (without sentry- prefix)
77+
# @param third_party_header [String, nil] Existing baggage header with third-party items
78+
# @return [String] Combined baggage header string
79+
def self.serialize_with_third_party(sentry_items, third_party_header)
80+
# Serialize sentry items
81+
sentry_baggage_items = sentry_items.map { |k, v| "#{SENTRY_PREFIX}#{CGI.escape(k)}=#{CGI.escape(v)}" }
82+
83+
# Parse third-party items
84+
third_party_items = []
85+
if third_party_header && !third_party_header.empty?
86+
third_party_header.split(",").each do |item|
87+
item = item.strip
88+
next if item.empty?
89+
next if item =~ SENTRY_PREFIX_REGEX
90+
third_party_items << item
91+
end
92+
end
93+
94+
# Combine items: sentry first, then third-party
95+
all_items = sentry_baggage_items + third_party_items
96+
97+
# Apply limits
98+
all_items = apply_limits(all_items)
99+
100+
all_items.join(",")
101+
end
102+
103+
private_class_method def self.apply_limits(items)
104+
# First, enforce member count limit
105+
# Since sentry items are always first in the array, take(MAX_MEMBER_COUNT)
106+
# naturally preserves sentry items and drops third-party items first
107+
items = items.take(MAX_MEMBER_COUNT) if items.size > MAX_MEMBER_COUNT
108+
109+
# Then, enforce byte size limit
110+
# Use greedy approach: add items in order until budget exhausted
111+
result = []
112+
current_size = 0
113+
114+
items.each do |item|
115+
item_size = item.bytesize + (result.empty? ? 0 : 1) # +1 for comma separator
116+
next if current_size + item_size > MAX_BAGGAGE_BYTES
117+
118+
result << item
119+
current_size += item_size
120+
end
121+
122+
result
123+
end
69124
end
70125
end

sentry-ruby/lib/sentry/utils/http_tracing.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,17 @@ def set_span_info(sentry_span, request_info, response_status)
1414
def set_propagation_headers(req)
1515
Sentry.get_trace_propagation_headers&.each do |k, v|
1616
if k == BAGGAGE_HEADER_NAME && req[k]
17-
req[k] = "#{v},#{req[k]}"
17+
# Use Baggage.serialize_with_third_party to respect W3C limits
18+
# Get the baggage object directly to avoid parse-serialize round-trip
19+
scope = Sentry.get_current_scope
20+
baggage = scope&.get_span&.transaction&.get_baggage || scope&.propagation_context&.get_baggage
21+
22+
if baggage
23+
req[k] = Baggage.serialize_with_third_party(baggage.items, req[k])
24+
else
25+
# Fallback to preserve third-party baggage if baggage object is unavailable
26+
req[k] = "#{v},#{req[k]}"
27+
end
1828
else
1929
req[k] = v
2030
end

sentry-ruby/spec/sentry/baggage_spec.rb

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,129 @@
100100
expect(baggage.mutable).to eq(false)
101101
end
102102
end
103+
104+
describe ".serialize_with_third_party" do
105+
let(:sentry_items) do
106+
{
107+
"trace_id" => "771a43a4192642f0b136d5159a501700",
108+
"public_key" => "49d0f7386ad645858ae85020e393bef3",
109+
"sample_rate" => "0.01337"
110+
}
111+
end
112+
113+
context "when combined baggage is within limits" do
114+
it "includes both sentry and third-party items unchanged" do
115+
third_party_header = "routingKey=myvalue,tenantId=123"
116+
result = described_class.serialize_with_third_party(sentry_items, third_party_header)
117+
118+
expect(result).to include("sentry-trace_id=771a43a4192642f0b136d5159a501700")
119+
expect(result).to include("sentry-public_key=49d0f7386ad645858ae85020e393bef3")
120+
expect(result).to include("sentry-sample_rate=0.01337")
121+
expect(result).to include("routingKey=myvalue")
122+
expect(result).to include("tenantId=123")
123+
end
124+
end
125+
126+
context "when exceeding MAX_MEMBER_COUNT (64)" do
127+
it "drops third-party items first" do
128+
# Create 10 sentry items
129+
many_sentry_items = (0...10).each_with_object({}) do |i, hash|
130+
hash["key#{i}"] = "value#{i}"
131+
end
132+
133+
# Create 60 third-party items (total would be 70, exceeds 64)
134+
third_party_items = (0...60).map { |i| "third#{i}=val#{i}" }.join(",")
135+
136+
result = described_class.serialize_with_third_party(many_sentry_items, third_party_items)
137+
138+
# All 10 sentry items should be present
139+
(0...10).each do |i|
140+
expect(result).to include("sentry-key#{i}=value#{i}")
141+
end
142+
143+
# Count total items (should be 64 max)
144+
total_items = result.split(",").size
145+
expect(total_items).to be <= 64
146+
147+
# Some third-party items should be dropped
148+
third_party_count = result.split(",").count { |item| item.start_with?("third") }
149+
expect(third_party_count).to be < 60
150+
end
151+
end
152+
153+
context "when exceeding MAX_BAGGAGE_BYTES (8192)" do
154+
it "drops third-party items first" do
155+
# Create sentry items that are ~2KB
156+
large_sentry_items = (0...5).each_with_object({}) do |i, hash|
157+
hash["key#{i}"] = "x" * 350
158+
end
159+
160+
# Create third-party items that would push us over 8192 bytes
161+
large_third_party = (0...20).map { |i| "third#{i}=#{'y' * 350}" }.join(",")
162+
163+
result = described_class.serialize_with_third_party(large_sentry_items, large_third_party)
164+
165+
# All sentry items should be present
166+
(0...5).each do |i|
167+
expect(result).to include("sentry-key#{i}=")
168+
end
169+
170+
# Total size should not exceed 8192 bytes
171+
expect(result.bytesize).to be <= 8192
172+
173+
# Some third-party items should be dropped
174+
third_party_count = result.split(",").count { |item| item.start_with?("third") }
175+
expect(third_party_count).to be < 20
176+
end
177+
end
178+
179+
context "when sentry items alone exceed limits" do
180+
it "drops sentry items to fit within limits" do
181+
# Create 70 sentry items (exceeds 64)
182+
many_sentry_items = (0...70).each_with_object({}) do |i, hash|
183+
hash["key#{i}"] = "value#{i}"
184+
end
185+
186+
result = described_class.serialize_with_third_party(many_sentry_items, nil)
187+
188+
# Should have exactly 64 items
189+
total_items = result.split(",").size
190+
expect(total_items).to eq(64)
191+
end
192+
193+
it "drops sentry items to fit within byte limit" do
194+
# Create sentry items that exceed 8192 bytes
195+
large_sentry_items = (0...30).each_with_object({}) do |i, hash|
196+
hash["key#{i}"] = "x" * 400
197+
end
198+
199+
result = described_class.serialize_with_third_party(large_sentry_items, nil)
200+
201+
# Should not exceed byte limit
202+
expect(result.bytesize).to be <= 8192
203+
204+
# Should have dropped some items
205+
total_items = result.split(",").size
206+
expect(total_items).to be < 30
207+
end
208+
end
209+
210+
context "when third_party_header is nil or empty" do
211+
it "handles nil third-party header" do
212+
result = described_class.serialize_with_third_party(sentry_items, nil)
213+
214+
expect(result).to include("sentry-trace_id=771a43a4192642f0b136d5159a501700")
215+
expect(result).to include("sentry-public_key=49d0f7386ad645858ae85020e393bef3")
216+
expect(result).to include("sentry-sample_rate=0.01337")
217+
end
218+
219+
it "handles empty third-party header" do
220+
result = described_class.serialize_with_third_party(sentry_items, "")
221+
222+
expect(result).to include("sentry-trace_id=771a43a4192642f0b136d5159a501700")
223+
expect(result).to include("sentry-public_key=49d0f7386ad645858ae85020e393bef3")
224+
expect(result).to include("sentry-sample_rate=0.01337")
225+
end
226+
end
227+
end
103228
end

sentry-ruby/spec/sentry/net/http_spec.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,59 @@
231231
expect(request["baggage"]).to eq(request_span.to_baggage)
232232
end
233233

234+
context "when respecting W3C baggage limits" do
235+
it "respects member count limit when merging with pre-existing baggage" do
236+
stub_normal_response
237+
238+
uri = URI("http://example.com/path")
239+
http = Net::HTTP.new(uri.host, uri.port)
240+
request = Net::HTTP::Get.new(uri.request_uri)
241+
242+
# Create a large pre-existing baggage with 60 items
243+
large_baggage = (0...60).map { |i| "key#{i}=value#{i}" }.join(",")
244+
request["baggage"] = large_baggage
245+
246+
transaction = Sentry.start_transaction
247+
Sentry.get_current_scope.set_span(transaction)
248+
249+
response = http.request(request)
250+
251+
expect(response.code).to eq("200")
252+
253+
# Check that the total member count doesn't exceed 64
254+
baggage_items = request["baggage"].split(",")
255+
expect(baggage_items.size).to be <= 64
256+
257+
# Sentry items should still be present
258+
expect(request["baggage"]).to include("sentry-trace_id")
259+
end
260+
261+
it "respects byte limit when merging with pre-existing baggage" do
262+
stub_normal_response
263+
264+
uri = URI("http://example.com/path")
265+
http = Net::HTTP.new(uri.host, uri.port)
266+
request = Net::HTTP::Get.new(uri.request_uri)
267+
268+
# Create a large pre-existing baggage that would exceed 8192 bytes with sentry items
269+
large_baggage = (0...30).map { |i| "key#{i}=#{'x' * 250}" }.join(",")
270+
request["baggage"] = large_baggage
271+
272+
transaction = Sentry.start_transaction
273+
Sentry.get_current_scope.set_span(transaction)
274+
275+
response = http.request(request)
276+
277+
expect(response.code).to eq("200")
278+
279+
# Check that the total byte size doesn't exceed 8192
280+
expect(request["baggage"].bytesize).to be <= 8192
281+
282+
# Sentry items should still be present
283+
expect(request["baggage"]).to include("sentry-trace_id")
284+
end
285+
end
286+
234287
context "with config.propagate_traces = false" do
235288
before do
236289
Sentry.configuration.propagate_traces = false

0 commit comments

Comments
 (0)