From 42f2b62d30e2e0729ba6f1263c4583e021997438 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 28 Jul 2025 14:21:44 +0000 Subject: [PATCH 1/8] Add Utils::SampleRand --- sentry-ruby/lib/sentry-ruby.rb | 1 + sentry-ruby/lib/sentry/utils/sample_rand.rb | 48 +++++ .../spec/sentry/utils/sample_rand_spec.rb | 172 ++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 sentry-ruby/lib/sentry/utils/sample_rand.rb create mode 100644 sentry-ruby/spec/sentry/utils/sample_rand_spec.rb diff --git a/sentry-ruby/lib/sentry-ruby.rb b/sentry-ruby/lib/sentry-ruby.rb index a2a43b4e3..986e6d1a8 100644 --- a/sentry-ruby/lib/sentry-ruby.rb +++ b/sentry-ruby/lib/sentry-ruby.rb @@ -10,6 +10,7 @@ require "sentry/utils/argument_checking_helper" require "sentry/utils/encoding_helper" require "sentry/utils/logging_helper" +require "sentry/utils/sample_rand" require "sentry/configuration" require "sentry/structured_logger" require "sentry/event" diff --git a/sentry-ruby/lib/sentry/utils/sample_rand.rb b/sentry-ruby/lib/sentry/utils/sample_rand.rb new file mode 100644 index 000000000..6eb83319b --- /dev/null +++ b/sentry-ruby/lib/sentry/utils/sample_rand.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Sentry + module Utils + module SampleRand + def self.generate_from_trace_id(trace_id) + (random_from_trace_id(trace_id) * 1_000_000).floor / 1_000_000.0 + end + + def self.generate_from_sampling_decision(sampled, sample_rate, trace_id = nil) + if sample_rate.nil? || sample_rate <= 0.0 || sample_rate > 1.0 + trace_id ? generate_from_trace_id(trace_id) : format(Random.rand(1.0)).to_f + else + random = random_from_trace_id(trace_id) + + if sampled + format(random * sample_rate) + elsif sample_rate == 1.0 + format(random) + else + format(sample_rate + random * (1.0 - sample_rate)) + end.to_f + end + end + + def self.random_from_trace_id(trace_id) + if trace_id + Random.new(trace_id[0, 16].to_i(16)) + else + Random.new + end.rand(1.0) + end + + def self.valid?(sample_rand) + return false unless sample_rand + return false if sample_rand.is_a?(String) && sample_rand.empty? + + value = sample_rand.is_a?(String) ? sample_rand.to_f : sample_rand + value >= 0.0 && value < 1.0 + end + + def self.format(sample_rand) + truncated = (sample_rand * 1_000_000).floor / 1_000_000.0 + "%.6f" % truncated + end + end + end +end diff --git a/sentry-ruby/spec/sentry/utils/sample_rand_spec.rb b/sentry-ruby/spec/sentry/utils/sample_rand_spec.rb new file mode 100644 index 000000000..3f6adcd02 --- /dev/null +++ b/sentry-ruby/spec/sentry/utils/sample_rand_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +RSpec.describe Sentry::Utils::SampleRand do + describe ".generate_from_trace_id" do + it "generates a float in range [0, 1) with 6 decimal places" do + trace_id = "abcdef1234567890abcdef1234567890" + sample_rand = described_class.generate_from_trace_id(trace_id) + + expect(sample_rand).to be_a(Float) + expect(sample_rand).to be >= 0.0 + expect(sample_rand).to be < 1.0 + expect(sample_rand.to_s.split('.')[1].length).to be <= 6 + end + + it "generates deterministic values for the same trace_id" do + trace_id = "abcdef1234567890abcdef1234567890" + + sample_rand1 = described_class.generate_from_trace_id(trace_id) + sample_rand2 = described_class.generate_from_trace_id(trace_id) + + expect(sample_rand1).to eq(sample_rand2) + end + + it "generates different values for different trace_ids" do + trace_id1 = "abcdef1234567890abcdef1234567890" + trace_id2 = "fedcba0987654321fedcba0987654321" + + sample_rand1 = described_class.generate_from_trace_id(trace_id1) + sample_rand2 = described_class.generate_from_trace_id(trace_id2) + + expect(sample_rand1).not_to eq(sample_rand2) + end + + it "handles short trace_ids" do + trace_id = "abc123" + sample_rand = described_class.generate_from_trace_id(trace_id) + + expect(sample_rand).to be_a(Float) + expect(sample_rand).to be >= 0.0 + expect(sample_rand).to be < 1.0 + end + end + + describe ".generate_from_sampling_decision" do + let(:trace_id) { "abcdef1234567890abcdef1234567890" } + + context "with valid sample_rate and sampled=true" do + it "generates value in range [0, sample_rate)" do + sample_rate = 0.5 + sample_rand = described_class.generate_from_sampling_decision(true, sample_rate, trace_id) + + expect(sample_rand).to be >= 0.0 + expect(sample_rand).to be < sample_rate + end + + it "is deterministic with trace_id" do + sample_rate = 0.5 + + sample_rand1 = described_class.generate_from_sampling_decision(true, sample_rate, trace_id) + sample_rand2 = described_class.generate_from_sampling_decision(true, sample_rate, trace_id) + + expect(sample_rand1).to eq(sample_rand2) + end + + it "never generates invalid values even with sample_rate = 1.0" do + result = described_class.generate_from_sampling_decision(true, 1.0, trace_id) + + expect(result).to be >= 0.0 + expect(result).to be < 1.0 + expect(described_class.valid?(result)).to be true + end + end + + context "with valid sample_rate and sampled=false" do + it "generates value in range [sample_rate, 1)" do + sample_rate = 0.3 + sample_rand = described_class.generate_from_sampling_decision(false, sample_rate, trace_id) + + expect(sample_rand).to be >= sample_rate + expect(sample_rand).to be < 1.0 + end + + it "is deterministic with trace_id" do + sample_rate = 0.3 + + sample_rand1 = described_class.generate_from_sampling_decision(false, sample_rate, trace_id) + sample_rand2 = described_class.generate_from_sampling_decision(false, sample_rate, trace_id) + + expect(sample_rand1).to eq(sample_rand2) + end + end + + context "with invalid sample_rate" do + it "falls back to trace_id generation when sample_rate is nil" do + expected = described_class.generate_from_trace_id(trace_id) + actual = described_class.generate_from_sampling_decision(true, nil, trace_id) + + expect(actual).to eq(expected) + end + + it "falls back to trace_id generation when sample_rate is 0" do + expected = described_class.generate_from_trace_id(trace_id) + actual = described_class.generate_from_sampling_decision(true, 0.0, trace_id) + + expect(actual).to eq(expected) + end + + it "falls back to trace_id generation when sample_rate > 1" do + expected = described_class.generate_from_trace_id(trace_id) + actual = described_class.generate_from_sampling_decision(true, 1.5, trace_id) + + expect(actual).to eq(expected) + end + + it "uses Random.rand when no trace_id provided" do + result = described_class.generate_from_sampling_decision(true, nil, nil) + + expect(result).to be_a(Float) + expect(result).to be >= 0.0 + expect(result).to be < 1.0 + expect(result.to_s.split('.')[1].length).to be <= 6 + end + + it "never generates values >= 1.0 even with edge case rounding" do + 1000.times do + result = described_class.generate_from_sampling_decision(true, nil, nil) + expect(result).to be < 1.0 + end + end + + it "handles edge case where sampled is false and sample_rate is 1.0" do + result = described_class.generate_from_sampling_decision(false, 1.0, "abcdef1234567890abcdef1234567890") + + expect(result).to be_a(Float) + expect(result).to be >= 0.0 + expect(result).to be < 1.0 + expect(described_class.valid?(result)).to be true + end + end + end + + describe ".valid?" do + it "returns true for valid float values" do + expect(described_class.valid?(0.0)).to be true + expect(described_class.valid?(0.5)).to be true + expect(described_class.valid?(0.999999)).to be true + end + + it "returns true for valid string values" do + expect(described_class.valid?("0.0")).to be true + expect(described_class.valid?("0.5")).to be true + expect(described_class.valid?("0.999999")).to be true + end + + it "returns false for invalid values" do + expect(described_class.valid?(nil)).to be false + expect(described_class.valid?(-0.1)).to be false + expect(described_class.valid?(1.0)).to be false + expect(described_class.valid?(1.5)).to be false + expect(described_class.valid?("")).to be false + end + end + + describe ".format" do + it "formats float to 6 decimal places" do + expect(described_class.format(0.123456789)).to eq("0.123456") + expect(described_class.format(0.9999999)).to eq("0.999999") + expect(described_class.format(0.1)).to eq("0.100000") + expect(described_class.format(0.0)).to eq("0.000000") + end + end +end From df46b6ec95d62ba8b811f605659423ff038dfd6a Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 28 Jul 2025 14:30:20 +0000 Subject: [PATCH 2/8] Add sample_rand to propagation context --- sentry-ruby/lib/sentry/propagation_context.rb | 35 +++- .../propagation_context/sample_rand_spec.rb | 160 ++++++++++++++++++ .../spec/sentry/propagation_context_spec.rb | 1 + 3 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 sentry-ruby/spec/sentry/propagation_context/sample_rand_spec.rb diff --git a/sentry-ruby/lib/sentry/propagation_context.rb b/sentry-ruby/lib/sentry/propagation_context.rb index 9fb847dbc..ab04a9c4c 100644 --- a/sentry-ruby/lib/sentry/propagation_context.rb +++ b/sentry-ruby/lib/sentry/propagation_context.rb @@ -3,6 +3,7 @@ require "securerandom" require "sentry/baggage" require "sentry/utils/uuid" +require "sentry/utils/sample_rand" module Sentry class PropagationContext @@ -33,6 +34,9 @@ class PropagationContext # Please use the #get_baggage method for interfacing outside this class. # @return [Baggage, nil] attr_reader :baggage + # The propagated random value used for sampling decisions. + # @return [Float, nil] + attr_reader :sample_rand def initialize(scope, env = nil) @scope = scope @@ -40,6 +44,7 @@ def initialize(scope, env = nil) @parent_sampled = nil @baggage = nil @incoming_trace = false + @sample_rand = nil if env sentry_trace_header = env["HTTP_SENTRY_TRACE"] || env[SENTRY_TRACE_HEADER_NAME] @@ -61,6 +66,7 @@ def initialize(scope, env = nil) Baggage.new({}) end + @sample_rand = extract_sample_rand_from_baggage(@baggage) @baggage.freeze! @incoming_trace = true end @@ -69,6 +75,7 @@ def initialize(scope, env = nil) @trace_id ||= Utils.uuid @span_id = Utils.uuid.slice(0, 16) + @sample_rand ||= generate_sample_rand end # Extract the trace_id, parent_span_id and parent_sampled values from a sentry-trace header. @@ -77,7 +84,7 @@ def initialize(scope, env = nil) # @return [Array, nil] def self.extract_sentry_trace(sentry_trace) match = SENTRY_TRACE_REGEXP.match(sentry_trace) - return nil if match.nil? + return if match.nil? trace_id, parent_span_id, sampled_flag = match[1..3] parent_sampled = sampled_flag.nil? ? nil : sampled_flag != "0" @@ -123,6 +130,7 @@ def populate_head_baggage items = { "trace_id" => trace_id, + "sample_rand" => Utils::SampleRand.format(@sample_rand), "environment" => configuration.environment, "release" => configuration.release, "public_key" => configuration.dsn&.public_key @@ -131,5 +139,30 @@ def populate_head_baggage items.compact! @baggage = Baggage.new(items, mutable: false) end + + def extract_sample_rand_from_baggage(baggage) + return unless baggage&.items + + sample_rand_str = baggage.items["sample_rand"] + return unless sample_rand_str + + sample_rand = sample_rand_str.to_f + Utils::SampleRand.valid?(sample_rand) ? sample_rand : nil + end + + def generate_sample_rand + if @incoming_trace && !@parent_sampled.nil? && @baggage + sample_rate_str = @baggage.items["sample_rate"] + sample_rate = sample_rate_str&.to_f + + if sample_rate && !@parent_sampled.nil? + Utils::SampleRand.generate_from_sampling_decision(@parent_sampled, sample_rate, @trace_id) + else + Utils::SampleRand.generate_from_trace_id(@trace_id) + end + else + Utils::SampleRand.generate_from_trace_id(@trace_id) + end + end end end diff --git a/sentry-ruby/spec/sentry/propagation_context/sample_rand_spec.rb b/sentry-ruby/spec/sentry/propagation_context/sample_rand_spec.rb new file mode 100644 index 000000000..d80b9d20d --- /dev/null +++ b/sentry-ruby/spec/sentry/propagation_context/sample_rand_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +RSpec.describe Sentry::PropagationContext do + before do + perform_basic_setup + end + + let(:scope) { Sentry.get_current_scope } + + describe "sample_rand integration" do + describe "#initialize" do + it "generates sample_rand when no incoming trace" do + context = described_class.new(scope) + + expect(context.sample_rand).to be_a(Float) + expect(context.sample_rand).to be >= 0.0 + expect(context.sample_rand).to be < 1.0 + end + + it "generates deterministic sample_rand from trace_id" do + context1 = described_class.new(scope) + context2 = described_class.new(scope) + + expect(context1.sample_rand).not_to eq(context2.sample_rand) + + trace_id = context1.trace_id + allow(Sentry::Utils).to receive(:uuid).and_return(trace_id) + context3 = described_class.new(scope) + + expect(context3.sample_rand).to eq(context1.sample_rand) + end + + context "with incoming trace" do + let(:env) do + { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=0.123456" + } + end + + it "uses sample_rand from incoming baggage" do + context = described_class.new(scope, env) + + expect(context.sample_rand).to eq(0.123456) + expect(context.incoming_trace).to be true + end + end + + context "with incoming trace but no sample_rand in baggage" do + let(:env) do + { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rate=0.5" + } + end + + it "generates sample_rand based on sampling decision" do + context = described_class.new(scope, env) + + expect(context.sample_rand).to be_a(Float) + expect(context.sample_rand).to be >= 0.0 + expect(context.sample_rand).to be < 1.0 + expect(context.incoming_trace).to be true + + expect(context.sample_rand).to be < 0.5 + end + + it "is deterministic for same trace" do + context1 = described_class.new(scope, env) + context2 = described_class.new(scope, env) + + expect(context1.sample_rand).to eq(context2.sample_rand) + end + end + + context "with incoming trace and parent_sampled=false" do + let(:env) do + { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-0", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rate=0.5" + } + end + + it "generates sample_rand based on unsampled decision" do + context = described_class.new(scope, env) + + expect(context.sample_rand).to be_a(Float) + expect(context.sample_rand).to be >= 0.0 + expect(context.sample_rand).to be < 1.0 + expect(context.incoming_trace).to be true + expect(context.parent_sampled).to be false + + expect(context.sample_rand).to be >= 0.5 + end + + it "is deterministic for same trace" do + context1 = described_class.new(scope, env) + context2 = described_class.new(scope, env) + + expect(context1.sample_rand).to eq(context2.sample_rand) + end + + it "uses parent's explicit unsampled decision instead of falling back to trace_id generation" do + context = described_class.new(scope, env) + + expected_from_decision = Sentry::Utils::SampleRand.generate_from_sampling_decision(false, 0.5, "771a43a4192642f0b136d5159a501700") + expected_from_trace_id = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + + expect(context.sample_rand).to eq(expected_from_decision) + expect(context.sample_rand).not_to eq(expected_from_trace_id) + end + end + + context "with incoming trace but no baggage" do + let(:env) do + { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1" + } + end + + it "generates deterministic sample_rand from trace_id" do + context = described_class.new(scope, env) + + expect(context.sample_rand).to be_a(Float) + expect(context.sample_rand).to be >= 0.0 + expect(context.sample_rand).to be < 1.0 + expect(context.incoming_trace).to be true + + expected = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + expect(context.sample_rand).to eq(expected) + end + end + end + + describe "#get_baggage" do + it "includes sample_rand in baggage" do + context = described_class.new(scope) + baggage = context.get_baggage + + expect(baggage.items["sample_rand"]).to eq(Sentry::Utils::SampleRand.format(context.sample_rand)) + end + + context "with incoming baggage containing sample_rand" do + let(:env) do + { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=0.654321" + } + end + + it "preserves incoming sample_rand in baggage" do + context = described_class.new(scope, env) + baggage = context.get_baggage + + expect(baggage.items["sample_rand"]).to eq("0.654321") + end + end + end + end +end diff --git a/sentry-ruby/spec/sentry/propagation_context_spec.rb b/sentry-ruby/spec/sentry/propagation_context_spec.rb index 2ea2960ff..cb2165f02 100644 --- a/sentry-ruby/spec/sentry/propagation_context_spec.rb +++ b/sentry-ruby/spec/sentry/propagation_context_spec.rb @@ -119,6 +119,7 @@ expect(baggage.mutable).to eq(false) expect(baggage.items).to eq({ "trace_id" => subject.trace_id, + "sample_rand" => Sentry::Utils::SampleRand.format(subject.sample_rand), "environment" => "test", "release" => "foobar", "public_key" => Sentry.configuration.dsn.public_key From 4965f37536923784db5de2ff8f92d7ba6e87625b Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 28 Jul 2025 14:31:44 +0000 Subject: [PATCH 3/8] Add sample_rand to transaction --- sentry-ruby/lib/sentry/transaction.rb | 40 +++- sentry-ruby/spec/sentry/client_spec.rb | 1 + sentry-ruby/spec/sentry/net/http_spec.rb | 1 + .../sentry/rack/capture_exceptions_spec.rb | 10 +- sentry-ruby/spec/sentry/transaction_spec.rb | 45 +++-- sentry-ruby/spec/sentry_spec.rb | 181 ++++++++++++++++-- 6 files changed, 239 insertions(+), 39 deletions(-) diff --git a/sentry-ruby/lib/sentry/transaction.rb b/sentry-ruby/lib/sentry/transaction.rb index 60e262cf0..6ccec422e 100644 --- a/sentry-ruby/lib/sentry/transaction.rb +++ b/sentry-ruby/lib/sentry/transaction.rb @@ -2,6 +2,7 @@ require "sentry/baggage" require "sentry/profiler" +require "sentry/utils/sample_rand" require "sentry/propagation_context" module Sentry @@ -57,12 +58,17 @@ class Transaction < Span # @return [Profiler] attr_reader :profiler + # Sample rand value generated from trace_id + # @return [String] + attr_reader :sample_rand + def initialize( hub:, name: nil, source: :custom, parent_sampled: nil, baggage: nil, + sample_rand: nil, **options ) super(transaction: self, **options) @@ -82,12 +88,15 @@ def initialize( @effective_sample_rate = nil @contexts = {} @measurements = {} + @sample_rand = sample_rand unless @hub.profiler_running? @profiler = @configuration.profiler_class.new(@configuration) end init_span_recorder + + @sample_rand ||= Utils::SampleRand.generate_from_trace_id(@trace_id) end # @deprecated use Sentry.continue_trace instead. @@ -123,12 +132,15 @@ def self.from_sentry_trace(sentry_trace, baggage: nil, hub: Sentry.get_current_h baggage.freeze! + sample_rand = extract_sample_rand_from_baggage(baggage, trace_id, parent_sampled) + new( trace_id: trace_id, parent_span_id: parent_span_id, parent_sampled: parent_sampled, hub: hub, baggage: baggage, + sample_rand: sample_rand, **options ) end @@ -139,6 +151,24 @@ def self.extract_sentry_trace(sentry_trace) PropagationContext.extract_sentry_trace(sentry_trace) end + def self.extract_sample_rand_from_baggage(baggage, trace_id, parent_sampled) + return Utils::SampleRand.generate_from_trace_id(trace_id) unless baggage&.items + + sample_rand_str = baggage.items["sample_rand"] + if sample_rand_str && Utils::SampleRand.valid?(sample_rand_str.to_f) + sample_rand_str.to_f + else + sample_rate_str = baggage.items["sample_rate"] + sample_rate = sample_rate_str&.to_f + + if sample_rate && parent_sampled != nil + Utils::SampleRand.generate_from_sampling_decision(parent_sampled, sample_rate, trace_id) + else + Utils::SampleRand.generate_from_trace_id(trace_id) + end + end + end + # @return [Hash] def to_hash hash = super @@ -153,6 +183,13 @@ def to_hash hash end + def parent_sample_rate + return unless @baggage&.items + + sample_rate_str = @baggage.items["sample_rate"] + sample_rate_str&.to_f + end + # @return [Transaction] def deep_dup copy = super @@ -225,7 +262,7 @@ def set_initial_sample_decision(sampling_context:) @effective_sample_rate /= 2**factor end - @sampled = Random.rand < @effective_sample_rate + @sampled = @sample_rand < @effective_sample_rate end if @sampled @@ -331,6 +368,7 @@ def populate_head_baggage items = { "trace_id" => trace_id, "sample_rate" => effective_sample_rate&.to_s, + "sample_rand" => Utils::SampleRand.format(@sample_rand), "sampled" => sampled&.to_s, "environment" => @environment, "release" => @release, diff --git a/sentry-ruby/spec/sentry/client_spec.rb b/sentry-ruby/spec/sentry/client_spec.rb index f455f6ca6..55b9129ee 100644 --- a/sentry-ruby/spec/sentry/client_spec.rb +++ b/sentry-ruby/spec/sentry/client_spec.rb @@ -273,6 +273,7 @@ def sentry_context expect(event.dynamic_sampling_context).to eq({ "environment" => "development", "public_key" => "12345", + "sample_rand" => Sentry::Utils::SampleRand.format(transaction.sample_rand), "sample_rate" => "1.0", "sampled" => "true", "transaction" => "test transaction", diff --git a/sentry-ruby/spec/sentry/net/http_spec.rb b/sentry-ruby/spec/sentry/net/http_spec.rb index 642e90d90..afe53f519 100644 --- a/sentry-ruby/spec/sentry/net/http_spec.rb +++ b/sentry-ruby/spec/sentry/net/http_spec.rb @@ -153,6 +153,7 @@ expect(request["baggage"]).to eq( "sentry-trace_id=#{transaction.trace_id},"\ "sentry-sample_rate=1.0,"\ + "sentry-sample_rand=#{Sentry::Utils::SampleRand.format(transaction.sample_rand)},"\ "sentry-sampled=true,"\ "sentry-environment=development,"\ "sentry-public_key=foobarbaz" diff --git a/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb b/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb index 7ff6f4e4a..6f64ba9fb 100644 --- a/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb +++ b/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb @@ -279,11 +279,11 @@ def verify_transaction_doesnt_inherit_external_transaction(transaction, external end def wont_be_sampled_by_sdk - allow(Random).to receive(:rand).and_return(1.0) + allow(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(1.0) end def will_be_sampled_by_sdk - allow(Random).to receive(:rand).and_return(0.3) + allow(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(0.3) end before do @@ -430,7 +430,7 @@ def will_be_sampled_by_sdk context "when the transaction is sampled" do before do - allow(Random).to receive(:rand).and_return(0.4) + allow(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(0.4) end it "starts a transaction and finishes it" do @@ -488,7 +488,7 @@ def will_be_sampled_by_sdk context "when the transaction is not sampled" do before do - allow(Random).to receive(:rand).and_return(0.6) + allow(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(1.0) end it "doesn't do anything" do @@ -506,7 +506,7 @@ def will_be_sampled_by_sdk context "when there's an exception" do before do - allow(Random).to receive(:rand).and_return(0.4) + allow(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(0.4) end it "still finishes the transaction" do diff --git a/sentry-ruby/spec/sentry/transaction_spec.rb b/sentry-ruby/spec/sentry/transaction_spec.rb index 4ee843479..4cd08ca7b 100644 --- a/sentry-ruby/spec/sentry/transaction_spec.rb +++ b/sentry-ruby/spec/sentry/transaction_spec.rb @@ -257,22 +257,24 @@ end it "uses traces_sample_rate for sampling (positive result)" do - allow(Random).to receive(:rand).and_return(0.4) + # Create transaction with sample_rand < sample_rate (0.5) to ensure sampled + transaction = described_class.new(op: "rack.request", hub: Sentry.get_current_hub, sample_rand: 0.4) - subject.set_initial_sample_decision(sampling_context: {}) - expect(subject.sampled).to eq(true) - expect(subject.effective_sample_rate).to eq(0.5) + transaction.set_initial_sample_decision(sampling_context: {}) + expect(transaction.sampled).to eq(true) + expect(transaction.effective_sample_rate).to eq(0.5) expect(string_io.string).to include( "[Tracing] Starting transaction" ) end it "uses traces_sample_rate for sampling (negative result)" do - allow(Random).to receive(:rand).and_return(0.6) + # Create transaction with sample_rand > sample_rate (0.5) to ensure not sampled + transaction = described_class.new(op: "rack.request", hub: Sentry.get_current_hub, sample_rand: 0.6) - subject.set_initial_sample_decision(sampling_context: {}) - expect(subject.sampled).to eq(false) - expect(subject.effective_sample_rate).to eq(0.5) + transaction.set_initial_sample_decision(sampling_context: {}) + expect(transaction.sampled).to eq(false) + expect(transaction.effective_sample_rate).to eq(0.5) expect(string_io.string).to include( "[Tracing] Discarding transaction because it's not included in the random sample (sampling rate = 0.5)" ) @@ -474,11 +476,21 @@ end it "submits the event with the transaction's hub by default" do - subject.instance_variable_set(:@hub, another_hub) + # Create transaction with the specific hub from the beginning + transaction = described_class.new( + op: "sql.query", + description: "SELECT * FROM users;", + status: "ok", + sampled: true, + parent_sampled: true, + name: "foo", + source: :view, + hub: another_hub + ) expect(another_hub).to receive(:capture_event) - subject.finish + transaction.finish end end @@ -511,9 +523,13 @@ it "records lost event with reason backpressure" do expect(Sentry.get_current_client.transport).to receive(:any_rate_limited?).and_return(true) Sentry.backpressure_monitor.run - allow(Random).to receive(:rand).and_return(0.6) - subject.finish + # Create transaction with sample_rand that will be rejected after backpressure downsampling + # With traces_sample_rate = 1.0 and downsample_factor = 1, effective rate becomes 0.5 + # So sample_rand = 0.6 > 0.5 will be rejected + transaction = described_class.new(hub: Sentry.get_current_hub, sample_rand: 0.6) + + transaction.finish expect(Sentry.get_current_client.transport).to have_recorded_lost_event(:backpressure, 'transaction') expect(Sentry.get_current_client.transport).to have_recorded_lost_event(:backpressure, 'span') end @@ -556,7 +572,8 @@ name: "foo", source: source, hub: Sentry.get_current_hub, - baggage: incoming_baggage + baggage: incoming_baggage, + sample_rand: 0.123456 # Use a known value for predictable testing ) transaction.set_initial_sample_decision(sampling_context: {}) @@ -574,6 +591,7 @@ expect(baggage.items).to eq({ "environment" => "development", "public_key" => "12345", + "sample_rand" => "0.123456", # Known value from transaction creation "trace_id" => subject.trace_id, "transaction"=>"foo", "sample_rate" => "1.0", @@ -630,6 +648,7 @@ expect(baggage.items).to eq({ "environment" => "development", "public_key" => "12345", + "sample_rand" => "0.123456", # Known value from transaction creation "trace_id" => subject.trace_id, "transaction"=>"foo", "sample_rate" => "1.0", diff --git a/sentry-ruby/spec/sentry_spec.rb b/sentry-ruby/spec/sentry_spec.rb index 5f0e2c97f..6265f8e26 100644 --- a/sentry-ruby/spec/sentry_spec.rb +++ b/sentry-ruby/spec/sentry_spec.rb @@ -457,8 +457,14 @@ expect(transaction.sampled).to eq(false) - allow(Random).to receive(:rand).and_return(0.4) - transaction = Sentry.continue_trace({ "sentry-trace" => not_sampled_trace }, op: "rack.request", name: "/payment") + transaction = Sentry.continue_trace( + { + "sentry-trace" => not_sampled_trace, + "baggage" => "sentry-sample_rand=0.4" + }, + op: "rack.request", + name: "/payment" + ) described_class.start_transaction(transaction: transaction) expect(transaction.sampled).to eq(true) @@ -470,42 +476,50 @@ end it "gives /payment 0.5 of rate" do - allow(Random).to receive(:rand).and_return(0.4) - transaction = described_class.start_transaction(op: "rack.request", name: "/payment") + transaction = Sentry::Transaction.new(hub: Sentry.get_current_hub, op: "rack.request", name: "/payment", sample_rand: 0.4) + + described_class.start_transaction(transaction: transaction) expect(transaction.sampled).to eq(true) - allow(Random).to receive(:rand).and_return(0.6) - transaction = described_class.start_transaction(op: "rack.request", name: "/payment") + transaction = Sentry::Transaction.new(hub: Sentry.get_current_hub, op: "rack.request", name: "/payment", sample_rand: 0.6) + + described_class.start_transaction(transaction: transaction) expect(transaction.sampled).to eq(false) end it "gives /api 0.2 of rate" do - allow(Random).to receive(:rand).and_return(0.1) - transaction = described_class.start_transaction(op: "rack.request", name: "/api") + transaction = Sentry::Transaction.new(hub: Sentry.get_current_hub, op: "rack.request", name: "/api", sample_rand: 0.1) + + described_class.start_transaction(transaction: transaction) expect(transaction.sampled).to eq(true) - allow(Random).to receive(:rand).and_return(0.3) - transaction = described_class.start_transaction(op: "rack.request", name: "/api") + transaction = Sentry::Transaction.new(hub: Sentry.get_current_hub, op: "rack.request", name: "/api", sample_rand: 0.3) + + described_class.start_transaction(transaction: transaction) expect(transaction.sampled).to eq(false) end it "gives other paths 0.1 of rate" do - allow(Random).to receive(:rand).and_return(0.05) - transaction = described_class.start_transaction(op: "rack.request", name: "/orders") + transaction = Sentry::Transaction.new(hub: Sentry.get_current_hub, op: "rack.request", name: "/orders", sample_rand: 0.05) + + described_class.start_transaction(transaction: transaction) expect(transaction.sampled).to eq(true) - allow(Random).to receive(:rand).and_return(0.2) - transaction = described_class.start_transaction(op: "rack.request", name: "/orders") + transaction = Sentry::Transaction.new(hub: Sentry.get_current_hub, op: "rack.request", name: "/orders", sample_rand: 0.2) + + described_class.start_transaction(transaction: transaction) expect(transaction.sampled).to eq(false) end it "gives sidekiq ops 0.01 of rate" do - allow(Random).to receive(:rand).and_return(0.005) - transaction = described_class.start_transaction(op: "sidekiq") + transaction = Sentry::Transaction.new(hub: Sentry.get_current_hub, op: "sidekiq", sample_rand: 0.005) + + described_class.start_transaction(transaction: transaction) expect(transaction.sampled).to eq(true) - allow(Random).to receive(:rand).and_return(0.02) - transaction = described_class.start_transaction(op: "sidekiq") + transaction = Sentry::Transaction.new(hub: Sentry.get_current_hub, op: "sidekiq", sample_rand: 0.02) + + described_class.start_transaction(transaction: transaction) expect(transaction.sampled).to eq(false) end end @@ -860,7 +874,7 @@ baggage = described_class.get_baggage propagation_context = described_class.get_current_scope.propagation_context - expect(baggage).to eq("sentry-trace_id=#{propagation_context.trace_id},sentry-environment=development,sentry-public_key=12345") + expect(baggage).to eq("sentry-trace_id=#{propagation_context.trace_id},sentry-sample_rand=#{Sentry::Utils::SampleRand.format(propagation_context.sample_rand)},sentry-environment=development,sentry-public_key=12345") end it "returns a valid baggage header from scope current span" do @@ -870,7 +884,7 @@ baggage = described_class.get_baggage - expect(baggage).to eq("sentry-trace_id=#{span.trace_id},sentry-sampled=true,sentry-environment=development,sentry-public_key=12345") + expect(baggage).to eq("sentry-trace_id=#{span.trace_id},sentry-sample_rand=#{Sentry::Utils::SampleRand.format(transaction.sample_rand)},sentry-sampled=true,sentry-environment=development,sentry-public_key=12345") end end @@ -953,6 +967,133 @@ expect(transaction.baggage.items).to eq(incoming_prop_context.get_baggage.items) expect(transaction.baggage.mutable).to eq(false) end + + describe "sample_rand propagation" do + before do + Sentry.configuration.traces_sample_rate = 1.0 + end + + context "with sample_rand in incoming baggage" do + let(:env) do + { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=0.123456" + } + end + + it "propagates sample_rand from PropagationContext to Transaction" do + # This is the critical test that would have caught the bug + transaction = described_class.continue_trace(env, name: "test") + + # Get the PropagationContext that was created + propagation_context = Sentry.get_current_scope.propagation_context + + # Verify PropagationContext extracted sample_rand from baggage + expect(propagation_context.sample_rand).to eq(0.123456) + + # Verify Transaction received the same sample_rand (this was the bug) + expect(transaction.sample_rand).to eq(0.123456) + end + + it "uses propagated sample_rand for sampling decision" do + # Test with sample_rand that should result in sampling + transaction = described_class.continue_trace(env, name: "test") + described_class.start_transaction(transaction: transaction) + + # With sample_rand=0.123456 and traces_sample_rate=1.0, should be sampled + expect(transaction.sampled).to be true + end + + it "maintains sample_rand consistency across the trace" do + transaction = described_class.continue_trace(env, name: "test") + + # The sample_rand should be included in outgoing baggage + baggage = transaction.get_baggage + expect(baggage.items["sample_rand"]).to eq("0.123456") + end + end + + context "with different sample_rand values" do + it "correctly propagates various sample_rand values" do + test_values = [0.000001, 0.123456, 0.500000, 0.999999] + + test_values.each do |sample_rand_value| + env = { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=#{sample_rand_value}" + } + + transaction = described_class.continue_trace(env, name: "test") + expect(transaction.sample_rand).to eq(sample_rand_value) + end + end + end + + context "without sample_rand in incoming baggage" do + let(:env) do + { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700" + } + end + + it "propagates deterministic sample_rand from PropagationContext to Transaction" do + transaction = described_class.continue_trace(env, name: "test") + + # Get the PropagationContext that was created + propagation_context = Sentry.get_current_scope.propagation_context + + # Both should have the same deterministic sample_rand + expect(transaction.sample_rand).to eq(propagation_context.sample_rand) + + # Should be deterministic based on trace_id + expected = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + expect(transaction.sample_rand).to eq(expected) + end + end + + context "with invalid sample_rand in baggage" do + let(:env) do + { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=1.5" + } + end + + it "falls back to deterministic sample_rand generation" do + transaction = described_class.continue_trace(env, name: "test") + + # Should fall back to deterministic generation + expected = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + expect(transaction.sample_rand).to eq(expected) + end + end + + context "sampling decision consistency" do + it "makes consistent sampling decisions based on sample_rand" do + # Test case where sample_rand < sample_rate (should be sampled) + env_sampled = { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=0.3" + } + + Sentry.configuration.traces_sample_rate = 0.5 + transaction_sampled = described_class.continue_trace(env_sampled, name: "test") + described_class.start_transaction(transaction: transaction_sampled) + expect(transaction_sampled.sampled).to be true + + # Test case where sample_rand >= sample_rate (should not be sampled) + env_not_sampled = { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=0.7" + } + + transaction_not_sampled = described_class.continue_trace(env_not_sampled, name: "test") + described_class.start_transaction(transaction: transaction_not_sampled) + expect(transaction_not_sampled.sampled).to be false + end + end + end end end From 5d3b601a37574ead93e6189f95e421463cb55486 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 28 Jul 2025 14:35:09 +0000 Subject: [PATCH 4/8] Add parent_sampled_rate to sampling context --- sentry-ruby/lib/sentry/hub.rb | 4 +- sentry-ruby/spec/sentry/hub_spec.rb | 106 ++++++++++ .../sample_rand_propagation_spec.rb | 119 ++++++++++++ .../transactions/trace_propagation_spec.rb | 181 ++++++++++++++++++ 4 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 sentry-ruby/spec/sentry/transactions/sample_rand_propagation_spec.rb create mode 100644 sentry-ruby/spec/sentry/transactions/trace_propagation_spec.rb diff --git a/sentry-ruby/lib/sentry/hub.rb b/sentry-ruby/lib/sentry/hub.rb index c930902e8..53250288d 100644 --- a/sentry-ruby/lib/sentry/hub.rb +++ b/sentry-ruby/lib/sentry/hub.rb @@ -120,7 +120,8 @@ def start_transaction(transaction: nil, custom_sampling_context: {}, instrumente sampling_context = { transaction_context: transaction.to_hash, - parent_sampled: transaction.parent_sampled + parent_sampled: transaction.parent_sampled, + parent_sample_rate: transaction.parent_sample_rate } sampling_context.merge!(custom_sampling_context) @@ -357,6 +358,7 @@ def continue_trace(env, **options) parent_span_id: propagation_context.parent_span_id, parent_sampled: propagation_context.parent_sampled, baggage: propagation_context.baggage, + sample_rand: propagation_context.sample_rand, **options ) end diff --git a/sentry-ruby/spec/sentry/hub_spec.rb b/sentry-ruby/spec/sentry/hub_spec.rb index b6c5884e5..2ed462cc9 100644 --- a/sentry-ruby/spec/sentry/hub_spec.rb +++ b/sentry-ruby/spec/sentry/hub_spec.rb @@ -641,4 +641,110 @@ expect(transport.events.count).to eq(0) end end + + describe "#continue_trace" do + before do + configuration.traces_sample_rate = 1.0 + end + + context "without incoming sentry trace" do + let(:env) { { "HTTP_FOO" => "bar" } } + + it "returns nil" do + expect(subject.continue_trace(env)).to be_nil + end + + it "generates new propagation context on scope" do + subject.continue_trace(env) + + propagation_context = subject.current_scope.propagation_context + expect(propagation_context.incoming_trace).to be false + end + end + + context "with incoming sentry trace" do + context "with sample_rand in baggage" do + let(:env) do + { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=0.123456" + } + end + + it "creates Transaction with sample_rand from PropagationContext" do + transaction = subject.continue_trace(env, name: "test_transaction") + + expect(transaction).to be_a(Sentry::Transaction) + + propagation_context = subject.current_scope.propagation_context + + expect(propagation_context.sample_rand).to eq(0.123456) + expect(transaction.sample_rand).to eq(0.123456) + end + + it "creates Transaction with correct trace properties" do + transaction = subject.continue_trace(env, name: "test_transaction") + + expect(transaction.trace_id).to eq("771a43a4192642f0b136d5159a501700") + expect(transaction.parent_span_id).to eq("7c51afd529da4a2a") + expect(transaction.parent_sampled).to be true + end + + it "preserves baggage in Transaction" do + transaction = subject.continue_trace(env, name: "test_transaction") + + baggage = transaction.get_baggage + + expect(baggage.items["trace_id"]).to eq("771a43a4192642f0b136d5159a501700") + expect(baggage.items["sample_rand"]).to eq("0.123456") + end + end + + context "without sample_rand in baggage" do + let(:env) do + { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700" + } + end + + it "creates Transaction with deterministic sample_rand" do + transaction = subject.continue_trace(env, name: "test_transaction") + + propagation_context = subject.current_scope.propagation_context + + expect(transaction.sample_rand).to eq(propagation_context.sample_rand) + + expected = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + + expect(transaction.sample_rand).to eq(expected) + end + end + + context "with tracing disabled" do + before do + configuration.traces_sample_rate = nil + end + + let(:env) do + { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=0.123456" + } + end + + it "returns nil even with valid incoming trace" do + expect(subject.continue_trace(env)).to be_nil + end + + it "still generates propagation context on scope" do + subject.continue_trace(env) + + propagation_context = subject.current_scope.propagation_context + expect(propagation_context.incoming_trace).to be true + expect(propagation_context.sample_rand).to eq(0.123456) + end + end + end + end end diff --git a/sentry-ruby/spec/sentry/transactions/sample_rand_propagation_spec.rb b/sentry-ruby/spec/sentry/transactions/sample_rand_propagation_spec.rb new file mode 100644 index 000000000..083064a5f --- /dev/null +++ b/sentry-ruby/spec/sentry/transactions/sample_rand_propagation_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +RSpec.describe "Transactions and sample rand propagation" do + before do + perform_basic_setup do |config| + config.traces_sample_rate = 1.0 + end + end + + describe "sample_rand propagation" do + it "uses value from the baggage" do + env = { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=0.123456" + } + + transaction = Sentry.continue_trace(env, name: "backend_request", op: "http.server") + propagation_context = Sentry.get_current_scope.propagation_context + + expect(propagation_context.sample_rand).to eq(0.123456) + expect(transaction.sample_rand).to eq(0.123456) + + expect(transaction.sample_rand).to eq(propagation_context.sample_rand) + end + + it "generates deterministic value from trace id if there's no value in the baggage" do + env = { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700" + } + + transaction = Sentry.continue_trace(env, name: "backend_request", op: "http.server") + propagation_context = Sentry.get_current_scope.propagation_context + + expect(transaction.sample_rand).to eq(propagation_context.sample_rand) + + expected = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + + expect(transaction.sample_rand).to eq(expected) + expect(propagation_context.sample_rand).to eq(expected) + end + + it "properly handles sampling decisions" do + test_cases = [ + { sample_rand: 0.1, sample_rate: 0.5, should_sample: true }, + { sample_rand: 0.7, sample_rate: 0.5, should_sample: false }, + { sample_rand: 0.5, sample_rate: 0.5, should_sample: false }, + { sample_rand: 0.499999, sample_rate: 0.5, should_sample: true } + ] + + test_cases.each do |test_case| + Sentry.configuration.traces_sample_rate = test_case[:sample_rate] + + env = { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=#{test_case[:sample_rand]}" + } + + transaction = Sentry.continue_trace(env, name: "test") + Sentry.start_transaction(transaction: transaction) + + expect(transaction.sampled).to eq(test_case[:should_sample]) + end + end + + it "ensures baggage propagation includes correct sample_rand" do + env = { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=0.654321" + } + + transaction = Sentry.continue_trace(env, name: "backend_request") + baggage = transaction.get_baggage + + expect(baggage.items["sample_rand"]).to eq("0.654321") + + Sentry.get_current_scope.set_span(transaction) + + headers = Sentry.get_trace_propagation_headers + + expect(headers["baggage"]).to include("sentry-sample_rand=0.654321") + end + + it "handles edge cases and invalid sample_rand values" do + env = { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=1.5" + } + + transaction = Sentry.continue_trace(env, name: "test") + propagation_context = Sentry.get_current_scope.propagation_context + + expected = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + + expect(transaction.sample_rand).to eq(expected) + expect(propagation_context.sample_rand).to eq(expected) + end + + it "works correctly with multiple sequential requests" do + requests = [ + { sample_rand: 0.111111, trace_id: "11111111111111111111111111111111" }, + { sample_rand: 0.222222, trace_id: "22222222222222222222222222222222" }, + { sample_rand: 0.333333, trace_id: "33333333333333333333333333333333" } + ] + + requests.each do |request| + env = { + "HTTP_SENTRY_TRACE" => "#{request[:trace_id]}-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=#{request[:trace_id]},sentry-sample_rand=#{request[:sample_rand]}" + } + + transaction = Sentry.continue_trace(env, name: "test") + + expect(transaction.sample_rand).to eq(request[:sample_rand]) + expect(transaction.trace_id).to eq(request[:trace_id]) + end + end + end +end diff --git a/sentry-ruby/spec/sentry/transactions/trace_propagation_spec.rb b/sentry-ruby/spec/sentry/transactions/trace_propagation_spec.rb new file mode 100644 index 000000000..e83e195e6 --- /dev/null +++ b/sentry-ruby/spec/sentry/transactions/trace_propagation_spec.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +RSpec.describe "Trace propagation" do + before do + perform_basic_setup do |config| + config.traces_sample_rate = 0.5 + + config.traces_sampler = lambda do |sampling_context| + parent_sample_rate = sampling_context[:parent_sample_rate] + + if parent_sample_rate + parent_sample_rate + else + 0.5 + end + end + end + end + + describe "end-to-end propagated sampling" do + it "maintains consistent sampling across distributed trace" do + root_transaction = Sentry.start_transaction(name: "root", op: "http.server") + + Sentry.get_current_scope.set_span(root_transaction) + + headers = Sentry.get_trace_propagation_headers + + expect(headers).to include("sentry-trace", "baggage") + expect(headers["baggage"]).to include("sentry-sample_rand=") + + baggage = root_transaction.get_baggage + sample_rand_from_baggage = baggage.items["sample_rand"] + + expect(sample_rand_from_baggage).to match(/\A\d+\.\d{6}\z/) + + sentry_trace = headers["sentry-trace"] + baggage_header = headers["baggage"] + + child_transaction = Sentry::Transaction.from_sentry_trace( + sentry_trace, + baggage: baggage_header + ) + + expect(child_transaction).not_to be_nil + + Sentry.get_current_scope.set_span(child_transaction) + + started_child = Sentry.start_transaction(transaction: child_transaction) + + expect(started_child.sampled).to eq(root_transaction.sampled) + expect(started_child.effective_sample_rate).to eq(root_transaction.effective_sample_rate) + + child_headers = Sentry.get_trace_propagation_headers + + expect(child_headers["baggage"]).to include("sentry-sample_rand=") + + child_baggage = started_child.get_baggage + child_sample_rand = child_baggage.items["sample_rand"] + + expect(child_sample_rand).to eq(sample_rand_from_baggage) + end + + it "handles missing sample_rand gracefully" do + sentry_trace = "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1" + baggage_header = "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rate=0.25" + + transaction = Sentry::Transaction.from_sentry_trace(sentry_trace, baggage: baggage_header) + + expect(transaction).not_to be_nil + + expected_sample_rand = Sentry::Utils::SampleRand.generate_from_sampling_decision( + true, + 0.25, + "771a43a4192642f0b136d5159a501700" + ) + + expect(expected_sample_rand).to be >= 0.0 + expect(expected_sample_rand).to be < 1.0 + expect(expected_sample_rand).to be < 0.25 + + expected_sample_rand2 = Sentry::Utils::SampleRand.generate_from_sampling_decision( + true, 0.25, "771a43a4192642f0b136d5159a501700" + ) + expect(expected_sample_rand2).to eq(expected_sample_rand) + + baggage = transaction.get_baggage + + expect(baggage.items).to eq({ + "trace_id" => "771a43a4192642f0b136d5159a501700", + "sample_rate" => "0.25" + }) + end + + it "works with PropagationContext for tracing without performance" do + env = { + "HTTP_SENTRY_TRACE" => "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1", + "HTTP_BAGGAGE" => "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=0.123456" + } + + scope = Sentry.get_current_scope + propagation_context = Sentry::PropagationContext.new(scope, env) + + expect(propagation_context.sample_rand).to eq(0.123456) + + baggage = propagation_context.get_baggage + expect(baggage.items["sample_rand"]).to eq("0.123456") + end + + it "demonstrates deterministic sampling behavior" do + trace_id = "771a43a4192642f0b136d5159a501700" + + results = 5.times.map do + transaction = Sentry::Transaction.new(trace_id: trace_id, hub: Sentry.get_current_hub) + Sentry.start_transaction(transaction: transaction) + transaction.sampled + end + + expect(results.uniq.length).to eq(1) + + sample_rands = 5.times.map do + transaction = Sentry::Transaction.new(trace_id: trace_id, hub: Sentry.get_current_hub) + baggage = transaction.get_baggage + baggage.items["sample_rand"] + end + + expect(sample_rands.uniq.length).to eq(1) + + expected_sample_rand = Sentry::Utils::SampleRand.format( + Sentry::Utils::SampleRand.generate_from_trace_id(trace_id) + ) + expect(sample_rands.first).to eq(expected_sample_rand) + end + + it "works with custom traces_sampler" do + sampling_contexts = [] + + Sentry.configuration.traces_sampler = lambda do |context| + sampling_contexts << context + context[:parent_sample_rate] || 0.5 + end + + baggage = Sentry::Baggage.new({ "sample_rate" => "0.75" }) + + parent_transaction = Sentry::Transaction.new( + hub: Sentry.get_current_hub, + baggage: baggage, + sample_rand: 0.6 + ) + + Sentry.start_transaction(transaction: parent_transaction) + + expect(sampling_contexts.last[:parent_sample_rate]).to eq(0.75) + expect(parent_transaction.sampled).to be true + + transaction_baggage = parent_transaction.get_baggage + + expect(transaction_baggage.items["sample_rand"]).to eq("0.600000") + end + + it "handles invalid sample_rand in baggage" do + sentry_trace = "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1" + baggage_header = "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sample_rand=1.5" + + transaction = Sentry::Transaction.from_sentry_trace(sentry_trace, baggage: baggage_header) + + expect(transaction).not_to be_nil + + expected_sample_rand = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + + expect(expected_sample_rand).to be >= 0.0 + expect(expected_sample_rand).to be < 1.0 + + baggage = transaction.get_baggage + + expect(baggage.items).to eq({ + "trace_id" => "771a43a4192642f0b136d5159a501700", + "sample_rand" => "1.5" + }) + end + end +end From a7ddedae274e51b1bbd86963595297554cb5cb89 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 5 Aug 2025 21:18:14 +0000 Subject: [PATCH 5/8] More e2e coverage --- spec/features/tracing_spec.rb | 133 ++++++++++++++++++++++++++++++++-- spec/support/test_helper.rb | 2 +- 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/spec/features/tracing_spec.rb b/spec/features/tracing_spec.rb index 231727b80..3474fb963 100644 --- a/spec/features/tracing_spec.rb +++ b/spec/features/tracing_spec.rb @@ -16,22 +16,26 @@ error_events = logged_events[:events].select { |event| event["exception"] } expect(error_events).not_to be_empty - error_event = error_events.first + error_event = error_events.last exception_values = error_event.dig("exception", "values") expect(exception_values).not_to be_empty expect(exception_values.first["type"]).to eq("ZeroDivisionError") transaction_events = logged_events[:events].select { |event| event["type"] == "transaction" } - expect(error_event.dig("contexts", "trace")).not_to be_nil + expect(error_event.dig("contexts", "trace")).not_to be(nil) error_trace_id = error_event.dig("contexts", "trace", "trace_id") expect(error_trace_id).to_not be(nil) if transaction_events.any? - transaction_event = transaction_events.first - trace_context = transaction_event.dig("contexts", "trace") + transaction_event = transaction_events.find do |event| + event.dig("contexts", "trace", "trace_id") == error_trace_id + end - expect(trace_context).not_to be_nil + expect(transaction_event).not_to be(nil) + + trace_context = transaction_event.dig("contexts", "trace") + expect(trace_context).not_to be(nil) transaction_trace_id = trace_context["trace_id"] @@ -59,4 +63,123 @@ end end end + + describe "propagated random value behavior" do + it "properly propagates and uses sample_rand for sampling decisions in backend transactions" do + visit "/error" + + expect(page).to have_content("Svelte Mini App") + expect(page).to have_button("Trigger Error") + + click_button "trigger-error-btn" + + expect(page).to have_content("Error:") + + expect(logged_events[:event_count]).to be > 0 + + transaction_events = logged_events[:events].select { |event| event["type"] == "transaction" } + expect(transaction_events).not_to be_empty + + transactions = transaction_events.select { |event| + event.dig("contexts", "trace", "op") == "http.server" + } + + expect(transactions).not_to be_empty + + transaction = transactions.first + expect(transaction).not_to be(nil) + + trace_context = transaction.dig("contexts", "trace") + expect(trace_context).not_to be(nil) + expect(trace_context["trace_id"]).not_to be(nil) + + dsc = transaction.dig("_meta", "dsc") + if dsc && dsc["sample_rand"] + sample_rand = dsc["sample_rand"] + + expect(sample_rand).to match(/^\d+\.\d{1,6}$/) + + sample_rand_value = sample_rand.to_f + expect(sample_rand_value).to be >= 0.0 + expect(sample_rand_value).to be < 1.0 + end + + logged_events[:envelopes].each do |envelope| + envelope["items"].each do |item| + next unless dsc = item.dig("payload", "_meta", "dsc") + + sample_rand = dsc["sample_rand"] + expect(sample_rand).to match(/^\d+\.\d{1,6}$/) + + sample_rand_value = sample_rand.to_f + expect(sample_rand_value).to be >= 0.0 + expect(sample_rand_value).to be < 1.0 + + item["payload"]["spans"].each do |span| + sample_rand = span["data"]["sentry.sample_rand"] + + expect(sample_rand).to be_a(Float) + expect(sample_rand).to be >= 0.0 + expect(sample_rand).to be < 1.0 + end + end + end + end + + it "verifies sampling decisions are based on propagated sample_rand" do + visit "/error" + + expect(page).to have_content("Svelte Mini App") + expect(page).to have_button("Trigger Error") + + click_button "trigger-error-btn" + + expect(page).to have_content("Error:") + + transaction_events = logged_events[:events].select { |event| event["type"] == "transaction" } + expect(transaction_events).not_to be_empty + + transaction_events.each do |transaction| + dsc = transaction.dig("_meta", "dsc") + next unless dsc && dsc["sample_rand"] + + sample_rand = dsc["sample_rand"].to_f + sample_rate = dsc["sample_rate"]&.to_f + + expect(sample_rand).to be >= 0.0 + expect(sample_rand).to be < 1.0 + + expect(sample_rate).to be > 0.0 + expect(sample_rate).to be <= 1.0 + + expect(dsc["sample_rand"]).to match(/^\d+\.\d{1,6}$/) + end + end + + it "maintains consistent sample_rand across multiple requests in the same trace" do + visit "/error" + + expect(page).to have_content("Svelte Mini App") + + 3.times do |i| + click_button "trigger-error-btn" + sleep 0.1 + end + + expect(page).to have_content("Error:") + + transaction_events = logged_events[:events].select { |event| event["type"] == "transaction" } + expect(transaction_events.length).to be >= 2 + + traces = transaction_events.group_by { |event| event.dig("contexts", "trace", "trace_id") } + + traces.each do |trace_id, transactions| + next if transactions.length < 2 + + sample_rands = transactions.map { |transaction| transaction.dig("_meta", "dsc", "sample_rand") }.compact.uniq + + expect(sample_rands.length).to eq(1) + end + end + end end diff --git a/spec/support/test_helper.rb b/spec/support/test_helper.rb index 3a5349f3b..b54243790 100644 --- a/spec/support/test_helper.rb +++ b/spec/support/test_helper.rb @@ -16,7 +16,7 @@ def logged_events } event_data["items"].each do |item| - if item["headers"]["type"] == "event" + if ["event", "transaction"].include?(item["headers"]["type"]) extracted_events << item["payload"] end end From 0fdbceb0409f15c5f2095afa750ffcc2a3451788 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 5 Aug 2025 21:22:52 +0000 Subject: [PATCH 6/8] Update CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 226075393..4c9f8d319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +### Feature + +- Propagated sampling rates as specified in [Traces](https://develop.sentry.dev/sdk/telemetry/traces/#propagated-random-value) docs ([#2671](https://github.com/getsentry/sentry-ruby/pull/2671)) + ### Internal - Factor out do_request in HTTP transport ([#2662](https://github.com/getsentry/sentry-ruby/pull/2662)) From 763c8add58b71a35bca2c448bd82392a7932f23e Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 13 Aug 2025 13:17:28 +0000 Subject: [PATCH 7/8] Turn SampleRand into a class --- sentry-ruby/lib/sentry/propagation_context.rb | 12 +- sentry-ruby/lib/sentry/transaction.rb | 32 ++++-- sentry-ruby/lib/sentry/utils/sample_rand.rb | 102 ++++++++++++----- sentry-ruby/spec/sentry/hub_spec.rb | 3 +- .../propagation_context/sample_rand_spec.rb | 10 +- .../sentry/rack/capture_exceptions_spec.rb | 10 +- sentry-ruby/spec/sentry/transaction_spec.rb | 40 +++++++ .../sample_rand_propagation_spec.rb | 6 +- .../transactions/trace_propagation_spec.rb | 21 ++-- .../spec/sentry/utils/sample_rand_spec.rb | 108 ++++++++++++------ sentry-ruby/spec/sentry_spec.rb | 6 +- 11 files changed, 245 insertions(+), 105 deletions(-) diff --git a/sentry-ruby/lib/sentry/propagation_context.rb b/sentry-ruby/lib/sentry/propagation_context.rb index ab04a9c4c..a5d14332c 100644 --- a/sentry-ruby/lib/sentry/propagation_context.rb +++ b/sentry-ruby/lib/sentry/propagation_context.rb @@ -146,22 +146,24 @@ def extract_sample_rand_from_baggage(baggage) sample_rand_str = baggage.items["sample_rand"] return unless sample_rand_str - sample_rand = sample_rand_str.to_f - Utils::SampleRand.valid?(sample_rand) ? sample_rand : nil + generator = Utils::SampleRand.new(trace_id: @trace_id) + generator.generate_from_value(sample_rand_str) end def generate_sample_rand + generator = Utils::SampleRand.new(trace_id: @trace_id) + if @incoming_trace && !@parent_sampled.nil? && @baggage sample_rate_str = @baggage.items["sample_rate"] sample_rate = sample_rate_str&.to_f if sample_rate && !@parent_sampled.nil? - Utils::SampleRand.generate_from_sampling_decision(@parent_sampled, sample_rate, @trace_id) + generator.generate_from_sampling_decision(@parent_sampled, sample_rate) else - Utils::SampleRand.generate_from_trace_id(@trace_id) + generator.generate_from_trace_id end else - Utils::SampleRand.generate_from_trace_id(@trace_id) + generator.generate_from_trace_id end end end diff --git a/sentry-ruby/lib/sentry/transaction.rb b/sentry-ruby/lib/sentry/transaction.rb index 6ccec422e..16a44c483 100644 --- a/sentry-ruby/lib/sentry/transaction.rb +++ b/sentry-ruby/lib/sentry/transaction.rb @@ -96,7 +96,10 @@ def initialize( init_span_recorder - @sample_rand ||= Utils::SampleRand.generate_from_trace_id(@trace_id) + unless @sample_rand + generator = Utils::SampleRand.new(trace_id: @trace_id) + @sample_rand = generator.generate_from_trace_id + end end # @deprecated use Sentry.continue_trace instead. @@ -152,20 +155,25 @@ def self.extract_sentry_trace(sentry_trace) end def self.extract_sample_rand_from_baggage(baggage, trace_id, parent_sampled) - return Utils::SampleRand.generate_from_trace_id(trace_id) unless baggage&.items + generator = Utils::SampleRand.new(trace_id: trace_id) + + unless baggage&.items + return generator.generate_from_trace_id + end sample_rand_str = baggage.items["sample_rand"] - if sample_rand_str && Utils::SampleRand.valid?(sample_rand_str.to_f) - sample_rand_str.to_f - else - sample_rate_str = baggage.items["sample_rate"] - sample_rate = sample_rate_str&.to_f - if sample_rate && parent_sampled != nil - Utils::SampleRand.generate_from_sampling_decision(parent_sampled, sample_rate, trace_id) - else - Utils::SampleRand.generate_from_trace_id(trace_id) - end + if sample_rand_str + return generator.generate_from_value(sample_rand_str) + end + + sample_rate_str = baggage.items["sample_rate"] + sample_rate = sample_rate_str&.to_f + + if sample_rate && parent_sampled != nil + generator.generate_from_sampling_decision(parent_sampled, sample_rate) + else + generator.generate_from_trace_id end end diff --git a/sentry-ruby/lib/sentry/utils/sample_rand.rb b/sentry-ruby/lib/sentry/utils/sample_rand.rb index 6eb83319b..1889c73c0 100644 --- a/sentry-ruby/lib/sentry/utils/sample_rand.rb +++ b/sentry-ruby/lib/sentry/utils/sample_rand.rb @@ -2,46 +2,96 @@ module Sentry module Utils - module SampleRand - def self.generate_from_trace_id(trace_id) - (random_from_trace_id(trace_id) * 1_000_000).floor / 1_000_000.0 + class SampleRand + PRECISION = 1_000_000.0 + FORMAT_PRECISION = 6 + + attr_reader :trace_id + + def self.valid?(value) + return false unless value + value >= 0.0 && value < 1.0 + end + + def self.format(value) + return unless value + + truncated = (value * PRECISION).floor / PRECISION + "%.#{FORMAT_PRECISION}f" % truncated + end + + def initialize(trace_id: nil) + @trace_id = trace_id end - def self.generate_from_sampling_decision(sampled, sample_rate, trace_id = nil) - if sample_rate.nil? || sample_rate <= 0.0 || sample_rate > 1.0 - trace_id ? generate_from_trace_id(trace_id) : format(Random.rand(1.0)).to_f + def generate_from_trace_id + (random_from_trace_id * PRECISION).floor / PRECISION + end + + def generate_from_sampling_decision(sampled, sample_rate) + if invalid_sample_rate?(sample_rate) + fallback_generation else - random = random_from_trace_id(trace_id) - - if sampled - format(random * sample_rate) - elsif sample_rate == 1.0 - format(random) - else - format(sample_rate + random * (1.0 - sample_rate)) - end.to_f + generate_based_on_sampling(sampled, sample_rate) end end - def self.random_from_trace_id(trace_id) - if trace_id - Random.new(trace_id[0, 16].to_i(16)) + def generate_from_value(sample_rand_value) + parsed_value = parse_value(sample_rand_value) + + if self.class.valid?(parsed_value) + parsed_value + else + fallback_generation + end + end + + private + + def random_from_trace_id + if @trace_id + Random.new(@trace_id[0, 16].to_i(16)) else Random.new end.rand(1.0) end - def self.valid?(sample_rand) - return false unless sample_rand - return false if sample_rand.is_a?(String) && sample_rand.empty? + def invalid_sample_rate?(sample_rate) + sample_rate.nil? || sample_rate <= 0.0 || sample_rate > 1.0 + end - value = sample_rand.is_a?(String) ? sample_rand.to_f : sample_rand - value >= 0.0 && value < 1.0 + def fallback_generation + if @trace_id + (random_from_trace_id * PRECISION).floor / PRECISION + else + format_random(Random.rand(1.0)) + end + end + + def generate_based_on_sampling(sampled, sample_rate) + random = random_from_trace_id + + result = if sampled + random * sample_rate + elsif sample_rate == 1.0 + random + else + sample_rate + random * (1.0 - sample_rate) + end + + format_random(result) end - def self.format(sample_rand) - truncated = (sample_rand * 1_000_000).floor / 1_000_000.0 - "%.6f" % truncated + def format_random(value) + truncated = (value * PRECISION).floor / PRECISION + ("%.#{FORMAT_PRECISION}f" % truncated).to_f + end + + def parse_value(sample_rand_value) + return unless sample_rand_value + return if sample_rand_value.is_a?(String) && sample_rand_value.empty? + + sample_rand_value.is_a?(String) ? sample_rand_value.to_f : sample_rand_value end end end diff --git a/sentry-ruby/spec/sentry/hub_spec.rb b/sentry-ruby/spec/sentry/hub_spec.rb index 2ed462cc9..f2f549d0d 100644 --- a/sentry-ruby/spec/sentry/hub_spec.rb +++ b/sentry-ruby/spec/sentry/hub_spec.rb @@ -715,7 +715,8 @@ expect(transaction.sample_rand).to eq(propagation_context.sample_rand) - expected = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + generator = Sentry::Utils::SampleRand.new(trace_id: "771a43a4192642f0b136d5159a501700") + expected = generator.generate_from_trace_id expect(transaction.sample_rand).to eq(expected) end diff --git a/sentry-ruby/spec/sentry/propagation_context/sample_rand_spec.rb b/sentry-ruby/spec/sentry/propagation_context/sample_rand_spec.rb index d80b9d20d..788e95e59 100644 --- a/sentry-ruby/spec/sentry/propagation_context/sample_rand_spec.rb +++ b/sentry-ruby/spec/sentry/propagation_context/sample_rand_spec.rb @@ -103,8 +103,11 @@ it "uses parent's explicit unsampled decision instead of falling back to trace_id generation" do context = described_class.new(scope, env) - expected_from_decision = Sentry::Utils::SampleRand.generate_from_sampling_decision(false, 0.5, "771a43a4192642f0b136d5159a501700") - expected_from_trace_id = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + generator1 = Sentry::Utils::SampleRand.new(trace_id: "771a43a4192642f0b136d5159a501700") + expected_from_decision = generator1.generate_from_sampling_decision(false, 0.5) + + generator2 = Sentry::Utils::SampleRand.new(trace_id: "771a43a4192642f0b136d5159a501700") + expected_from_trace_id = generator2.generate_from_trace_id expect(context.sample_rand).to eq(expected_from_decision) expect(context.sample_rand).not_to eq(expected_from_trace_id) @@ -126,7 +129,8 @@ expect(context.sample_rand).to be < 1.0 expect(context.incoming_trace).to be true - expected = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + generator = Sentry::Utils::SampleRand.new(trace_id: "771a43a4192642f0b136d5159a501700") + expected = generator.generate_from_trace_id expect(context.sample_rand).to eq(expected) end end diff --git a/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb b/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb index 6f64ba9fb..10cfa197a 100644 --- a/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb +++ b/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb @@ -279,11 +279,11 @@ def verify_transaction_doesnt_inherit_external_transaction(transaction, external end def wont_be_sampled_by_sdk - allow(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(1.0) + allow_any_instance_of(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(1.0) end def will_be_sampled_by_sdk - allow(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(0.3) + allow_any_instance_of(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(0.3) end before do @@ -430,7 +430,7 @@ def will_be_sampled_by_sdk context "when the transaction is sampled" do before do - allow(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(0.4) + allow_any_instance_of(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(0.4) end it "starts a transaction and finishes it" do @@ -488,7 +488,7 @@ def will_be_sampled_by_sdk context "when the transaction is not sampled" do before do - allow(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(1.0) + allow_any_instance_of(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(1.0) end it "doesn't do anything" do @@ -506,7 +506,7 @@ def will_be_sampled_by_sdk context "when there's an exception" do before do - allow(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(0.4) + allow_any_instance_of(Sentry::Utils::SampleRand).to receive(:generate_from_trace_id).and_return(0.4) end it "still finishes the transaction" do diff --git a/sentry-ruby/spec/sentry/transaction_spec.rb b/sentry-ruby/spec/sentry/transaction_spec.rb index 4cd08ca7b..7adc67186 100644 --- a/sentry-ruby/spec/sentry/transaction_spec.rb +++ b/sentry-ruby/spec/sentry/transaction_spec.rb @@ -658,6 +658,46 @@ end end + describe ".extract_sample_rand_from_baggage" do + let(:trace_id) { "771a43a4192642f0b136d5159a501700" } + + it "returns trace_id generation when baggage is nil" do + result = described_class.extract_sample_rand_from_baggage(nil, trace_id, true) + + generator = Sentry::Utils::SampleRand.new(trace_id: trace_id) + expected = generator.generate_from_trace_id + + expect(result).to eq(expected) + end + + it "returns trace_id generation when baggage has no items" do + baggage = double("baggage", items: nil) + result = described_class.extract_sample_rand_from_baggage(baggage, trace_id, true) + + generator = Sentry::Utils::SampleRand.new(trace_id: trace_id) + expected = generator.generate_from_trace_id + + expect(result).to eq(expected) + end + + it "returns trace_id generation when sample_rand is invalid" do + baggage = double("baggage", items: { "sample_rand" => "1.5" }) + result = described_class.extract_sample_rand_from_baggage(baggage, trace_id, true) + + generator = Sentry::Utils::SampleRand.new(trace_id: trace_id) + expected = generator.generate_from_trace_id + + expect(result).to eq(expected) + end + + it "returns valid sample_rand from baggage when present" do + baggage = double("baggage", items: { "sample_rand" => "0.5" }) + result = described_class.extract_sample_rand_from_baggage(baggage, trace_id, true) + + expect(result).to eq(0.5) + end + end + describe "#set_name" do it "sets name and source directly" do subject.set_name("bar", source: :url) diff --git a/sentry-ruby/spec/sentry/transactions/sample_rand_propagation_spec.rb b/sentry-ruby/spec/sentry/transactions/sample_rand_propagation_spec.rb index 083064a5f..9b24b9c58 100644 --- a/sentry-ruby/spec/sentry/transactions/sample_rand_propagation_spec.rb +++ b/sentry-ruby/spec/sentry/transactions/sample_rand_propagation_spec.rb @@ -34,7 +34,8 @@ expect(transaction.sample_rand).to eq(propagation_context.sample_rand) - expected = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + generator = Sentry::Utils::SampleRand.new(trace_id: "771a43a4192642f0b136d5159a501700") + expected = generator.generate_from_trace_id expect(transaction.sample_rand).to eq(expected) expect(propagation_context.sample_rand).to eq(expected) @@ -90,7 +91,8 @@ transaction = Sentry.continue_trace(env, name: "test") propagation_context = Sentry.get_current_scope.propagation_context - expected = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + generator = Sentry::Utils::SampleRand.new(trace_id: "771a43a4192642f0b136d5159a501700") + expected = generator.generate_from_trace_id expect(transaction.sample_rand).to eq(expected) expect(propagation_context.sample_rand).to eq(expected) diff --git a/sentry-ruby/spec/sentry/transactions/trace_propagation_spec.rb b/sentry-ruby/spec/sentry/transactions/trace_propagation_spec.rb index e83e195e6..59bf503d6 100644 --- a/sentry-ruby/spec/sentry/transactions/trace_propagation_spec.rb +++ b/sentry-ruby/spec/sentry/transactions/trace_propagation_spec.rb @@ -68,19 +68,15 @@ expect(transaction).not_to be_nil - expected_sample_rand = Sentry::Utils::SampleRand.generate_from_sampling_decision( - true, - 0.25, - "771a43a4192642f0b136d5159a501700" - ) + generator = Sentry::Utils::SampleRand.new(trace_id: "771a43a4192642f0b136d5159a501700") + expected_sample_rand = generator.generate_from_sampling_decision(true, 0.25) expect(expected_sample_rand).to be >= 0.0 expect(expected_sample_rand).to be < 1.0 expect(expected_sample_rand).to be < 0.25 - expected_sample_rand2 = Sentry::Utils::SampleRand.generate_from_sampling_decision( - true, 0.25, "771a43a4192642f0b136d5159a501700" - ) + generator2 = Sentry::Utils::SampleRand.new(trace_id: "771a43a4192642f0b136d5159a501700") + expected_sample_rand2 = generator2.generate_from_sampling_decision(true, 0.25) expect(expected_sample_rand2).to eq(expected_sample_rand) baggage = transaction.get_baggage @@ -125,9 +121,9 @@ expect(sample_rands.uniq.length).to eq(1) - expected_sample_rand = Sentry::Utils::SampleRand.format( - Sentry::Utils::SampleRand.generate_from_trace_id(trace_id) - ) + generator = Sentry::Utils::SampleRand.new(trace_id: trace_id) + value = generator.generate_from_trace_id + expected_sample_rand = Sentry::Utils::SampleRand.format(value) expect(sample_rands.first).to eq(expected_sample_rand) end @@ -165,7 +161,8 @@ expect(transaction).not_to be_nil - expected_sample_rand = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + generator = Sentry::Utils::SampleRand.new(trace_id: "771a43a4192642f0b136d5159a501700") + expected_sample_rand = generator.generate_from_trace_id expect(expected_sample_rand).to be >= 0.0 expect(expected_sample_rand).to be < 1.0 diff --git a/sentry-ruby/spec/sentry/utils/sample_rand_spec.rb b/sentry-ruby/spec/sentry/utils/sample_rand_spec.rb index 3f6adcd02..2cf8265d9 100644 --- a/sentry-ruby/spec/sentry/utils/sample_rand_spec.rb +++ b/sentry-ruby/spec/sentry/utils/sample_rand_spec.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true RSpec.describe Sentry::Utils::SampleRand do - describe ".generate_from_trace_id" do + describe "#generate_from_trace_id" do it "generates a float in range [0, 1) with 6 decimal places" do trace_id = "abcdef1234567890abcdef1234567890" - sample_rand = described_class.generate_from_trace_id(trace_id) + generator = described_class.new(trace_id: trace_id) + sample_rand = generator.generate_from_trace_id expect(sample_rand).to be_a(Float) expect(sample_rand).to be >= 0.0 @@ -15,8 +16,10 @@ it "generates deterministic values for the same trace_id" do trace_id = "abcdef1234567890abcdef1234567890" - sample_rand1 = described_class.generate_from_trace_id(trace_id) - sample_rand2 = described_class.generate_from_trace_id(trace_id) + generator1 = described_class.new(trace_id: trace_id) + generator2 = described_class.new(trace_id: trace_id) + sample_rand1 = generator1.generate_from_trace_id + sample_rand2 = generator2.generate_from_trace_id expect(sample_rand1).to eq(sample_rand2) end @@ -25,15 +28,18 @@ trace_id1 = "abcdef1234567890abcdef1234567890" trace_id2 = "fedcba0987654321fedcba0987654321" - sample_rand1 = described_class.generate_from_trace_id(trace_id1) - sample_rand2 = described_class.generate_from_trace_id(trace_id2) + generator1 = described_class.new(trace_id: trace_id1) + generator2 = described_class.new(trace_id: trace_id2) + sample_rand1 = generator1.generate_from_trace_id + sample_rand2 = generator2.generate_from_trace_id expect(sample_rand1).not_to eq(sample_rand2) end it "handles short trace_ids" do trace_id = "abc123" - sample_rand = described_class.generate_from_trace_id(trace_id) + generator = described_class.new(trace_id: trace_id) + sample_rand = generator.generate_from_trace_id expect(sample_rand).to be_a(Float) expect(sample_rand).to be >= 0.0 @@ -41,13 +47,14 @@ end end - describe ".generate_from_sampling_decision" do + describe "#generate_from_sampling_decision" do let(:trace_id) { "abcdef1234567890abcdef1234567890" } context "with valid sample_rate and sampled=true" do it "generates value in range [0, sample_rate)" do sample_rate = 0.5 - sample_rand = described_class.generate_from_sampling_decision(true, sample_rate, trace_id) + generator = described_class.new(trace_id: trace_id) + sample_rand = generator.generate_from_sampling_decision(true, sample_rate) expect(sample_rand).to be >= 0.0 expect(sample_rand).to be < sample_rate @@ -56,14 +63,17 @@ it "is deterministic with trace_id" do sample_rate = 0.5 - sample_rand1 = described_class.generate_from_sampling_decision(true, sample_rate, trace_id) - sample_rand2 = described_class.generate_from_sampling_decision(true, sample_rate, trace_id) + generator1 = described_class.new(trace_id: trace_id) + generator2 = described_class.new(trace_id: trace_id) + sample_rand1 = generator1.generate_from_sampling_decision(true, sample_rate) + sample_rand2 = generator2.generate_from_sampling_decision(true, sample_rate) expect(sample_rand1).to eq(sample_rand2) end it "never generates invalid values even with sample_rate = 1.0" do - result = described_class.generate_from_sampling_decision(true, 1.0, trace_id) + generator = described_class.new(trace_id: trace_id) + result = generator.generate_from_sampling_decision(true, 1.0) expect(result).to be >= 0.0 expect(result).to be < 1.0 @@ -74,7 +84,8 @@ context "with valid sample_rate and sampled=false" do it "generates value in range [sample_rate, 1)" do sample_rate = 0.3 - sample_rand = described_class.generate_from_sampling_decision(false, sample_rate, trace_id) + generator = described_class.new(trace_id: trace_id) + sample_rand = generator.generate_from_sampling_decision(false, sample_rate) expect(sample_rand).to be >= sample_rate expect(sample_rand).to be < 1.0 @@ -83,8 +94,10 @@ it "is deterministic with trace_id" do sample_rate = 0.3 - sample_rand1 = described_class.generate_from_sampling_decision(false, sample_rate, trace_id) - sample_rand2 = described_class.generate_from_sampling_decision(false, sample_rate, trace_id) + generator1 = described_class.new(trace_id: trace_id) + generator2 = described_class.new(trace_id: trace_id) + sample_rand1 = generator1.generate_from_sampling_decision(false, sample_rate) + sample_rand2 = generator2.generate_from_sampling_decision(false, sample_rate) expect(sample_rand1).to eq(sample_rand2) end @@ -92,28 +105,35 @@ context "with invalid sample_rate" do it "falls back to trace_id generation when sample_rate is nil" do - expected = described_class.generate_from_trace_id(trace_id) - actual = described_class.generate_from_sampling_decision(true, nil, trace_id) + generator1 = described_class.new(trace_id: trace_id) + generator2 = described_class.new(trace_id: trace_id) + expected = generator1.generate_from_trace_id + actual = generator2.generate_from_sampling_decision(true, nil) expect(actual).to eq(expected) end it "falls back to trace_id generation when sample_rate is 0" do - expected = described_class.generate_from_trace_id(trace_id) - actual = described_class.generate_from_sampling_decision(true, 0.0, trace_id) + generator1 = described_class.new(trace_id: trace_id) + generator2 = described_class.new(trace_id: trace_id) + expected = generator1.generate_from_trace_id + actual = generator2.generate_from_sampling_decision(true, 0.0) expect(actual).to eq(expected) end it "falls back to trace_id generation when sample_rate > 1" do - expected = described_class.generate_from_trace_id(trace_id) - actual = described_class.generate_from_sampling_decision(true, 1.5, trace_id) + generator1 = described_class.new(trace_id: trace_id) + generator2 = described_class.new(trace_id: trace_id) + expected = generator1.generate_from_trace_id + actual = generator2.generate_from_sampling_decision(true, 1.5) expect(actual).to eq(expected) end it "uses Random.rand when no trace_id provided" do - result = described_class.generate_from_sampling_decision(true, nil, nil) + generator = described_class.new + result = generator.generate_from_sampling_decision(true, nil) expect(result).to be_a(Float) expect(result).to be >= 0.0 @@ -123,13 +143,15 @@ it "never generates values >= 1.0 even with edge case rounding" do 1000.times do - result = described_class.generate_from_sampling_decision(true, nil, nil) + generator = described_class.new + result = generator.generate_from_sampling_decision(true, nil) expect(result).to be < 1.0 end end it "handles edge case where sampled is false and sample_rate is 1.0" do - result = described_class.generate_from_sampling_decision(false, 1.0, "abcdef1234567890abcdef1234567890") + generator = described_class.new(trace_id: "abcdef1234567890abcdef1234567890") + result = generator.generate_from_sampling_decision(false, 1.0) expect(result).to be_a(Float) expect(result).to be >= 0.0 @@ -139,25 +161,37 @@ end end - describe ".valid?" do - it "returns true for valid float values" do - expect(described_class.valid?(0.0)).to be true - expect(described_class.valid?(0.5)).to be true - expect(described_class.valid?(0.999999)).to be true + describe "#generate_from_value" do + it "accepts valid float values" do + generator = described_class.new + result = generator.generate_from_value(0.5) + expect(described_class.valid?(result)).to be true + expect(result).to eq(0.5) end - it "returns true for valid string values" do - expect(described_class.valid?("0.0")).to be true - expect(described_class.valid?("0.5")).to be true - expect(described_class.valid?("0.999999")).to be true + it "accepts valid string values" do + generator = described_class.new + result = generator.generate_from_value("0.5") + expect(described_class.valid?(result)).to be true + expect(result).to eq(0.5) + end + + it "falls back for invalid values" do + generator = described_class.new(trace_id: "abcdef1234567890abcdef1234567890") + result = generator.generate_from_value(1.5) + expect(described_class.valid?(result)).to be true + expect(result).to be >= 0.0 + expect(result).to be < 1.0 + end + end + + describe ".valid?" do + it "returns true for valid values" do + expect(described_class.valid?(0.5)).to be true end it "returns false for invalid values" do - expect(described_class.valid?(nil)).to be false - expect(described_class.valid?(-0.1)).to be false - expect(described_class.valid?(1.0)).to be false expect(described_class.valid?(1.5)).to be false - expect(described_class.valid?("")).to be false end end diff --git a/sentry-ruby/spec/sentry_spec.rb b/sentry-ruby/spec/sentry_spec.rb index 6265f8e26..00101bf1e 100644 --- a/sentry-ruby/spec/sentry_spec.rb +++ b/sentry-ruby/spec/sentry_spec.rb @@ -1047,7 +1047,8 @@ expect(transaction.sample_rand).to eq(propagation_context.sample_rand) # Should be deterministic based on trace_id - expected = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + generator = Sentry::Utils::SampleRand.new(trace_id: "771a43a4192642f0b136d5159a501700") + expected = generator.generate_from_trace_id expect(transaction.sample_rand).to eq(expected) end end @@ -1064,7 +1065,8 @@ transaction = described_class.continue_trace(env, name: "test") # Should fall back to deterministic generation - expected = Sentry::Utils::SampleRand.generate_from_trace_id("771a43a4192642f0b136d5159a501700") + generator = Sentry::Utils::SampleRand.new(trace_id: "771a43a4192642f0b136d5159a501700") + expected = generator.generate_from_trace_id expect(transaction.sample_rand).to eq(expected) end end From 04fe9276acaa6658f393b6ed2239e7e35cf653e9 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 14 Aug 2025 08:29:50 +0000 Subject: [PATCH 8/8] More tests --- .../sample_rand_propagation_spec.rb | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/sentry-ruby/spec/sentry/transactions/sample_rand_propagation_spec.rb b/sentry-ruby/spec/sentry/transactions/sample_rand_propagation_spec.rb index 9b24b9c58..f1bc56f2e 100644 --- a/sentry-ruby/spec/sentry/transactions/sample_rand_propagation_spec.rb +++ b/sentry-ruby/spec/sentry/transactions/sample_rand_propagation_spec.rb @@ -117,5 +117,26 @@ expect(transaction.trace_id).to eq(request[:trace_id]) end end + + it "handles corrupted trace context during transaction creation" do + # TODO: does it make sense to even handle such case? + transaction = Sentry::Transaction.new( + hub: Sentry.get_current_hub, + trace_id: nil, + name: "corrupted_trace_test", + op: "test" + ) + + expect(transaction.sample_rand).to be_a(Float) + expect(transaction.sample_rand).to be >= 0.0 + expect(transaction.sample_rand).to be < 1.0 + expect(Sentry::Utils::SampleRand.valid?(transaction.sample_rand)).to be true + + Sentry.start_transaction(transaction: transaction) + expect([true, false]).to include(transaction.sampled) + + baggage = transaction.get_baggage + expect(baggage.items["sample_rand"]).to match(/\A\d+\.\d{6}\z/) + end end end