diff --git a/sentry-ruby/lib/sentry/baggage.rb b/sentry-ruby/lib/sentry/baggage.rb index be97e58e4..b896fcee2 100644 --- a/sentry-ruby/lib/sentry/baggage.rb +++ b/sentry-ruby/lib/sentry/baggage.rb @@ -7,6 +7,8 @@ module Sentry class Baggage SENTRY_PREFIX = "sentry-" SENTRY_PREFIX_REGEX = /^sentry-/ + MAX_MEMBER_COUNT = 64 + MAX_BAGGAGE_BYTES = 8192 # @return [Hash] attr_reader :items @@ -66,5 +68,58 @@ def serialize items = @items.map { |k, v| "#{SENTRY_PREFIX}#{CGI.escape(k)}=#{CGI.escape(v)}" } items.join(",") end + + # Serialize sentry baggage items combined with third-party items from an existing header, + # respecting W3C limits (max 64 members, max 8192 bytes). + # Drops third-party items first when limits are exceeded, then sentry items if still over. + # + # @param sentry_items [Hash] Sentry baggage items (without sentry- prefix) + # @param third_party_header [String, nil] Existing baggage header with third-party items + # @return [String] Combined baggage header string + def self.serialize_with_third_party(sentry_items, third_party_header) + # Serialize sentry items + sentry_baggage_items = sentry_items.map { |k, v| "#{SENTRY_PREFIX}#{CGI.escape(k)}=#{CGI.escape(v)}" } + + # Parse third-party items + third_party_items = [] + if third_party_header && !third_party_header.empty? + third_party_header.split(",").each do |item| + item = item.strip + next if item.empty? + next if item =~ SENTRY_PREFIX_REGEX + third_party_items << item + end + end + + # Combine items: sentry first, then third-party + all_items = sentry_baggage_items + third_party_items + + # Apply limits + all_items = apply_limits(all_items) + + all_items.join(",") + end + + private_class_method def self.apply_limits(items) + # First, enforce member count limit + # Since sentry items are always first in the array, take(MAX_MEMBER_COUNT) + # naturally preserves sentry items and drops third-party items first + items = items.take(MAX_MEMBER_COUNT) if items.size > MAX_MEMBER_COUNT + + # Then, enforce byte size limit + # Use greedy approach: add items in order until budget exhausted + result = [] + current_size = 0 + + items.each do |item| + item_size = item.bytesize + (result.empty? ? 0 : 1) # +1 for comma separator + next if current_size + item_size > MAX_BAGGAGE_BYTES + + result << item + current_size += item_size + end + + result + end end end diff --git a/sentry-ruby/lib/sentry/utils/http_tracing.rb b/sentry-ruby/lib/sentry/utils/http_tracing.rb index 9dc72429e..1803c3076 100644 --- a/sentry-ruby/lib/sentry/utils/http_tracing.rb +++ b/sentry-ruby/lib/sentry/utils/http_tracing.rb @@ -14,7 +14,17 @@ def set_span_info(sentry_span, request_info, response_status) def set_propagation_headers(req) Sentry.get_trace_propagation_headers&.each do |k, v| if k == BAGGAGE_HEADER_NAME && req[k] - req[k] = "#{v},#{req[k]}" + # Use Baggage.serialize_with_third_party to respect W3C limits + # Get the baggage object directly to avoid parse-serialize round-trip + scope = Sentry.get_current_scope + baggage = scope&.get_span&.transaction&.get_baggage || scope&.propagation_context&.get_baggage + + if baggage + req[k] = Baggage.serialize_with_third_party(baggage.items, req[k]) + else + # Fallback to preserve third-party baggage if baggage object is unavailable + req[k] = "#{v},#{req[k]}" + end else req[k] = v end diff --git a/sentry-ruby/spec/sentry/baggage_spec.rb b/sentry-ruby/spec/sentry/baggage_spec.rb index 5acbf13ef..06230a272 100644 --- a/sentry-ruby/spec/sentry/baggage_spec.rb +++ b/sentry-ruby/spec/sentry/baggage_spec.rb @@ -100,4 +100,129 @@ expect(baggage.mutable).to eq(false) end end + + describe ".serialize_with_third_party" do + let(:sentry_items) do + { + "trace_id" => "771a43a4192642f0b136d5159a501700", + "public_key" => "49d0f7386ad645858ae85020e393bef3", + "sample_rate" => "0.01337" + } + end + + context "when combined baggage is within limits" do + it "includes both sentry and third-party items unchanged" do + third_party_header = "routingKey=myvalue,tenantId=123" + result = described_class.serialize_with_third_party(sentry_items, third_party_header) + + expect(result).to include("sentry-trace_id=771a43a4192642f0b136d5159a501700") + expect(result).to include("sentry-public_key=49d0f7386ad645858ae85020e393bef3") + expect(result).to include("sentry-sample_rate=0.01337") + expect(result).to include("routingKey=myvalue") + expect(result).to include("tenantId=123") + end + end + + context "when exceeding MAX_MEMBER_COUNT (64)" do + it "drops third-party items first" do + # Create 10 sentry items + many_sentry_items = (0...10).each_with_object({}) do |i, hash| + hash["key#{i}"] = "value#{i}" + end + + # Create 60 third-party items (total would be 70, exceeds 64) + third_party_items = (0...60).map { |i| "third#{i}=val#{i}" }.join(",") + + result = described_class.serialize_with_third_party(many_sentry_items, third_party_items) + + # All 10 sentry items should be present + (0...10).each do |i| + expect(result).to include("sentry-key#{i}=value#{i}") + end + + # Count total items (should be 64 max) + total_items = result.split(",").size + expect(total_items).to be <= 64 + + # Some third-party items should be dropped + third_party_count = result.split(",").count { |item| item.start_with?("third") } + expect(third_party_count).to be < 60 + end + end + + context "when exceeding MAX_BAGGAGE_BYTES (8192)" do + it "drops third-party items first" do + # Create sentry items that are ~2KB + large_sentry_items = (0...5).each_with_object({}) do |i, hash| + hash["key#{i}"] = "x" * 350 + end + + # Create third-party items that would push us over 8192 bytes + large_third_party = (0...20).map { |i| "third#{i}=#{'y' * 350}" }.join(",") + + result = described_class.serialize_with_third_party(large_sentry_items, large_third_party) + + # All sentry items should be present + (0...5).each do |i| + expect(result).to include("sentry-key#{i}=") + end + + # Total size should not exceed 8192 bytes + expect(result.bytesize).to be <= 8192 + + # Some third-party items should be dropped + third_party_count = result.split(",").count { |item| item.start_with?("third") } + expect(third_party_count).to be < 20 + end + end + + context "when sentry items alone exceed limits" do + it "drops sentry items to fit within limits" do + # Create 70 sentry items (exceeds 64) + many_sentry_items = (0...70).each_with_object({}) do |i, hash| + hash["key#{i}"] = "value#{i}" + end + + result = described_class.serialize_with_third_party(many_sentry_items, nil) + + # Should have exactly 64 items + total_items = result.split(",").size + expect(total_items).to eq(64) + end + + it "drops sentry items to fit within byte limit" do + # Create sentry items that exceed 8192 bytes + large_sentry_items = (0...30).each_with_object({}) do |i, hash| + hash["key#{i}"] = "x" * 400 + end + + result = described_class.serialize_with_third_party(large_sentry_items, nil) + + # Should not exceed byte limit + expect(result.bytesize).to be <= 8192 + + # Should have dropped some items + total_items = result.split(",").size + expect(total_items).to be < 30 + end + end + + context "when third_party_header is nil or empty" do + it "handles nil third-party header" do + result = described_class.serialize_with_third_party(sentry_items, nil) + + expect(result).to include("sentry-trace_id=771a43a4192642f0b136d5159a501700") + expect(result).to include("sentry-public_key=49d0f7386ad645858ae85020e393bef3") + expect(result).to include("sentry-sample_rate=0.01337") + end + + it "handles empty third-party header" do + result = described_class.serialize_with_third_party(sentry_items, "") + + expect(result).to include("sentry-trace_id=771a43a4192642f0b136d5159a501700") + expect(result).to include("sentry-public_key=49d0f7386ad645858ae85020e393bef3") + expect(result).to include("sentry-sample_rate=0.01337") + end + end + end end diff --git a/sentry-ruby/spec/sentry/net/http_spec.rb b/sentry-ruby/spec/sentry/net/http_spec.rb index f66d0a0e6..647fe20e6 100644 --- a/sentry-ruby/spec/sentry/net/http_spec.rb +++ b/sentry-ruby/spec/sentry/net/http_spec.rb @@ -231,6 +231,59 @@ expect(request["baggage"]).to eq(request_span.to_baggage) end + context "when respecting W3C baggage limits" do + it "respects member count limit when merging with pre-existing baggage" do + stub_normal_response + + uri = URI("http://example.com/path") + http = Net::HTTP.new(uri.host, uri.port) + request = Net::HTTP::Get.new(uri.request_uri) + + # Create a large pre-existing baggage with 60 items + large_baggage = (0...60).map { |i| "key#{i}=value#{i}" }.join(",") + request["baggage"] = large_baggage + + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + response = http.request(request) + + expect(response.code).to eq("200") + + # Check that the total member count doesn't exceed 64 + baggage_items = request["baggage"].split(",") + expect(baggage_items.size).to be <= 64 + + # Sentry items should still be present + expect(request["baggage"]).to include("sentry-trace_id") + end + + it "respects byte limit when merging with pre-existing baggage" do + stub_normal_response + + uri = URI("http://example.com/path") + http = Net::HTTP.new(uri.host, uri.port) + request = Net::HTTP::Get.new(uri.request_uri) + + # Create a large pre-existing baggage that would exceed 8192 bytes with sentry items + large_baggage = (0...30).map { |i| "key#{i}=#{'x' * 250}" }.join(",") + request["baggage"] = large_baggage + + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + response = http.request(request) + + expect(response.code).to eq("200") + + # Check that the total byte size doesn't exceed 8192 + expect(request["baggage"].bytesize).to be <= 8192 + + # Sentry items should still be present + expect(request["baggage"]).to include("sentry-trace_id") + end + end + context "with config.propagate_traces = false" do before do Sentry.configuration.propagate_traces = false