diff --git a/sentry-ruby/lib/sentry/configuration.rb b/sentry-ruby/lib/sentry/configuration.rb index 21fc48c9d..8bbf638b5 100644 --- a/sentry-ruby/lib/sentry/configuration.rb +++ b/sentry-ruby/lib/sentry/configuration.rb @@ -371,6 +371,20 @@ class Configuration # @return [Proc, nil] attr_reader :std_lib_logger_filter + # An optional organization ID. The SDK will try to extract it from the DSN in most cases + # but you can provide it explicitly for self-hosted and Relay setups. + # This value is used for trace propagation and for features like strict_trace_continuation. + # @return [String, nil] + attr_accessor :org_id + + # Controls whether the SDK requires matching org IDs from incoming baggage + # to continue a trace. When true, the SDK will start a new trace if the org_id + # from the incoming baggage does not match the SDK's own org_id, or if either + # side is missing an org_id (unless both are missing). + # Default is false. + # @return [Boolean] + attr_accessor :strict_trace_continuation + # these are not config options # @!visibility private attr_reader :errors, :gem_specs @@ -520,6 +534,8 @@ def initialize self.trusted_proxies = [] self.dsn = ENV["SENTRY_DSN"] self.capture_queue_time = true + self.org_id = nil + self.strict_trace_continuation = false spotlight_env = ENV["SENTRY_SPOTLIGHT"] spotlight_bool = Sentry::Utils::EnvHelper.env_to_bool(spotlight_env, strict: true) @@ -673,6 +689,12 @@ def profiler_class=(profiler_class) @profiler_class = profiler_class end + # Returns the effective org ID, preferring the explicit config option over the DSN-parsed value. + # @return [String, nil] + def effective_org_id + org_id || dsn&.org_id + end + def sending_allowed? spotlight || sending_to_dsn_allowed? end diff --git a/sentry-ruby/lib/sentry/dsn.rb b/sentry-ruby/lib/sentry/dsn.rb index 71ee00934..8fed8eae6 100644 --- a/sentry-ruby/lib/sentry/dsn.rb +++ b/sentry-ruby/lib/sentry/dsn.rb @@ -11,8 +11,9 @@ class DSN REQUIRED_ATTRIBUTES = %w[host path public_key project_id].freeze LOCALHOST_NAMES = %w[localhost 127.0.0.1 ::1 [::1]].freeze LOCALHOST_PATTERN = /\.local(host|domain)?$/i + ORG_ID_REGEX = /\Ao(\d+)\./ - attr_reader :scheme, :secret_key, :port, *REQUIRED_ATTRIBUTES + attr_reader :scheme, :secret_key, :port, :org_id, *REQUIRED_ATTRIBUTES def initialize(dsn_string) @raw_value = dsn_string @@ -31,6 +32,9 @@ def initialize(dsn_string) @host = uri.host @port = uri.port if uri.port @path = uri_path.join("/") + + # Extract org ID from the host (e.g., "o123.ingest.sentry.io" -> "123") + @org_id = extract_org_id_from_host end def valid? @@ -87,6 +91,14 @@ def resolved_ips_private? end end + # Override the org ID parsed from the DSN. + # This is used when the org_id config option is set explicitly. + # @param value [String] + # @return [void] + def org_id=(value) + @org_id = value + end + def generate_auth_header(client: nil) now = Sentry.utc_now.to_i @@ -101,5 +113,14 @@ def generate_auth_header(client: nil) "Sentry " + fields.map { |key, value| "#{key}=#{value}" }.join(", ") end + + private + + def extract_org_id_from_host + return nil unless @host + + match = ORG_ID_REGEX.match(@host) + match ? match[1] : nil + end end end diff --git a/sentry-ruby/lib/sentry/propagation_context.rb b/sentry-ruby/lib/sentry/propagation_context.rb index 86c5b3f22..e28862e20 100644 --- a/sentry-ruby/lib/sentry/propagation_context.rb +++ b/sentry-ruby/lib/sentry/propagation_context.rb @@ -53,6 +53,35 @@ def self.extract_sentry_trace(sentry_trace) [trace_id, parent_span_id, parent_sampled] end + # Determines whether we should continue an incoming trace based on org_id matching + # and the strict_trace_continuation configuration option. + # + # @param incoming_baggage [Baggage] the baggage from the incoming request + # @return [Boolean] + def self.should_continue_trace?(incoming_baggage) + return true unless Sentry.initialized? + + configuration = Sentry.configuration + sdk_org_id = configuration.effective_org_id + baggage_org_id = incoming_baggage&.items&.fetch("org_id", nil) + + # Mismatched org IDs always start a new trace regardless of strict mode + if sdk_org_id && baggage_org_id && sdk_org_id != baggage_org_id + return false + end + + # In strict mode, both must be present and match (unless both are missing) + if configuration.strict_trace_continuation + if sdk_org_id.nil? && baggage_org_id.nil? + return true + end + + return sdk_org_id == baggage_org_id + end + + true + end + def self.extract_sample_rand_from_baggage(baggage, trace_id = nil) return unless baggage&.items @@ -96,9 +125,7 @@ def initialize(scope, env = nil) sentry_trace_data = self.class.extract_sentry_trace(sentry_trace_header) if sentry_trace_data - @trace_id, @parent_span_id, @parent_sampled = sentry_trace_data - - @baggage = + incoming_baggage = if baggage_header && !baggage_header.empty? Baggage.from_incoming_header(baggage_header) else @@ -108,10 +135,13 @@ def initialize(scope, env = nil) Baggage.new({}) end - @sample_rand = self.class.extract_sample_rand_from_baggage(@baggage, @trace_id) - - @baggage.freeze! - @incoming_trace = true + if self.class.should_continue_trace?(incoming_baggage) + @trace_id, @parent_span_id, @parent_sampled = sentry_trace_data + @baggage = incoming_baggage + @sample_rand = self.class.extract_sample_rand_from_baggage(@baggage, @trace_id) + @baggage.freeze! + @incoming_trace = true + end end end end @@ -162,7 +192,8 @@ def populate_head_baggage "sample_rand" => Utils::SampleRand.format(@sample_rand), "environment" => configuration.environment, "release" => configuration.release, - "public_key" => configuration.dsn&.public_key + "public_key" => configuration.dsn&.public_key, + "org_id" => configuration.effective_org_id } items.compact! diff --git a/sentry-ruby/lib/sentry/transaction.rb b/sentry-ruby/lib/sentry/transaction.rb index 75f425dd2..01d23f345 100644 --- a/sentry-ruby/lib/sentry/transaction.rb +++ b/sentry-ruby/lib/sentry/transaction.rb @@ -295,7 +295,8 @@ def populate_head_baggage "sampled" => sampled&.to_s, "environment" => configuration&.environment, "release" => configuration&.release, - "public_key" => configuration&.dsn&.public_key + "public_key" => configuration&.dsn&.public_key, + "org_id" => configuration&.effective_org_id } items["transaction"] = name unless source_low_quality? diff --git a/sentry-ruby/spec/sentry/dsn_spec.rb b/sentry-ruby/spec/sentry/dsn_spec.rb index 87e48bceb..dffa938d8 100644 --- a/sentry-ruby/spec/sentry/dsn_spec.rb +++ b/sentry-ruby/spec/sentry/dsn_spec.rb @@ -44,6 +44,36 @@ end end + describe "#org_id" do + it "extracts org_id from DSN host with org prefix" do + dsn = described_class.new("https://key@o1234.ingest.sentry.io/42") + expect(dsn.org_id).to eq("1234") + end + + it "extracts single digit org_id" do + dsn = described_class.new("https://key@o1.ingest.us.sentry.io/42") + expect(dsn.org_id).to eq("1") + end + + it "returns nil when host does not have org prefix" do + dsn = described_class.new("http://12345:67890@sentry.localdomain:3000/sentry/42") + expect(dsn.org_id).to be_nil + end + + it "returns nil for non-standard host without o prefix" do + dsn = described_class.new("https://key@not_org_id.ingest.sentry.io/42") + expect(dsn.org_id).to be_nil + end + + it "can be overridden with org_id=" do + dsn = described_class.new("https://key@o1234.ingest.sentry.io/42") + expect(dsn.org_id).to eq("1234") + + dsn.org_id = "9999" + expect(dsn.org_id).to eq("9999") + end + end + describe "#local?" do it "returns true for localhost" do expect(described_class.new("http://12345:67890@localhost/sentry/42").local?).to eq(true) diff --git a/sentry-ruby/spec/sentry/net/http_spec.rb b/sentry-ruby/spec/sentry/net/http_spec.rb index afe53f519..bd1e3a8fc 100644 --- a/sentry-ruby/spec/sentry/net/http_spec.rb +++ b/sentry-ruby/spec/sentry/net/http_spec.rb @@ -156,7 +156,8 @@ "sentry-sample_rand=#{Sentry::Utils::SampleRand.format(transaction.sample_rand)},"\ "sentry-sampled=true,"\ "sentry-environment=development,"\ - "sentry-public_key=foobarbaz" + "sentry-public_key=foobarbaz,"\ + "sentry-org_id=447951" ) end diff --git a/sentry-ruby/spec/sentry/propagation_context_spec.rb b/sentry-ruby/spec/sentry/propagation_context_spec.rb index 418360de1..84b4ce1dc 100644 --- a/sentry-ruby/spec/sentry/propagation_context_spec.rb +++ b/sentry-ruby/spec/sentry/propagation_context_spec.rb @@ -133,6 +133,183 @@ end end + describe ".should_continue_trace?" do + # Decision matrix: + # | Baggage org | SDK org | strict | Result | + # |-------------|---------|--------|--------------| + # | 1 | 1 | false | Continue | + # | None | 1 | false | Continue | + # | 1 | None | false | Continue | + # | None | None | false | Continue | + # | 1 | 2 | false | Start new | + # | 1 | 1 | true | Continue | + # | None | 1 | true | Start new | + # | 1 | None | true | Start new | + # | None | None | true | Continue | + # | 1 | 2 | true | Start new | + + let(:sentry_trace) { "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1" } + + def make_env(sentry_trace:, baggage_org_id: nil) + baggage_parts = ["sentry-trace_id=771a43a4192642f0b136d5159a501700"] + baggage_parts << "sentry-org_id=#{baggage_org_id}" if baggage_org_id + + { + "sentry-trace" => sentry_trace, + "baggage" => baggage_parts.join(",") + } + end + + context "with strict_trace_continuation=false" do + it "continues when baggage org matches SDK org" do + perform_basic_setup do |config| + config.dsn = "https://key@o1.ingest.sentry.io/42" + config.strict_trace_continuation = false + end + + env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1") + propagation_context = described_class.new(scope, env) + expect(propagation_context.incoming_trace).to eq(true) + expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700") + end + + it "continues when baggage has no org but SDK has org" do + perform_basic_setup do |config| + config.dsn = "https://key@o1.ingest.sentry.io/42" + config.strict_trace_continuation = false + end + + env = make_env(sentry_trace: sentry_trace, baggage_org_id: nil) + propagation_context = described_class.new(scope, env) + expect(propagation_context.incoming_trace).to eq(true) + expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700") + end + + it "continues when baggage has org but SDK has no org" do + perform_basic_setup do |config| + config.strict_trace_continuation = false + end + + env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1") + propagation_context = described_class.new(scope, env) + expect(propagation_context.incoming_trace).to eq(true) + expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700") + end + + it "continues when neither has org" do + perform_basic_setup do |config| + config.strict_trace_continuation = false + end + + env = make_env(sentry_trace: sentry_trace, baggage_org_id: nil) + propagation_context = described_class.new(scope, env) + expect(propagation_context.incoming_trace).to eq(true) + expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700") + end + + it "starts new trace when orgs mismatch" do + perform_basic_setup do |config| + config.dsn = "https://key@o2.ingest.sentry.io/42" + config.strict_trace_continuation = false + end + + env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1") + propagation_context = described_class.new(scope, env) + expect(propagation_context.incoming_trace).to eq(false) + expect(propagation_context.trace_id).not_to eq("771a43a4192642f0b136d5159a501700") + end + end + + context "with strict_trace_continuation=true" do + it "continues when baggage org matches SDK org" do + perform_basic_setup do |config| + config.dsn = "https://key@o1.ingest.sentry.io/42" + config.strict_trace_continuation = true + end + + env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1") + propagation_context = described_class.new(scope, env) + expect(propagation_context.incoming_trace).to eq(true) + expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700") + end + + it "starts new trace when baggage has no org but SDK has org" do + perform_basic_setup do |config| + config.dsn = "https://key@o1.ingest.sentry.io/42" + config.strict_trace_continuation = true + end + + env = make_env(sentry_trace: sentry_trace, baggage_org_id: nil) + propagation_context = described_class.new(scope, env) + expect(propagation_context.incoming_trace).to eq(false) + expect(propagation_context.trace_id).not_to eq("771a43a4192642f0b136d5159a501700") + end + + it "starts new trace when baggage has org but SDK has no org" do + perform_basic_setup do |config| + config.strict_trace_continuation = true + end + + env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1") + propagation_context = described_class.new(scope, env) + expect(propagation_context.incoming_trace).to eq(false) + expect(propagation_context.trace_id).not_to eq("771a43a4192642f0b136d5159a501700") + end + + it "continues when neither has org" do + perform_basic_setup do |config| + config.strict_trace_continuation = true + end + + env = make_env(sentry_trace: sentry_trace, baggage_org_id: nil) + propagation_context = described_class.new(scope, env) + expect(propagation_context.incoming_trace).to eq(true) + expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700") + end + + it "starts new trace when orgs mismatch" do + perform_basic_setup do |config| + config.dsn = "https://key@o2.ingest.sentry.io/42" + config.strict_trace_continuation = true + end + + env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1") + propagation_context = described_class.new(scope, env) + expect(propagation_context.incoming_trace).to eq(false) + expect(propagation_context.trace_id).not_to eq("771a43a4192642f0b136d5159a501700") + end + end + + context "with explicit org_id config" do + it "uses explicit org_id over DSN-parsed org_id" do + perform_basic_setup do |config| + config.dsn = "https://key@o1234.ingest.sentry.io/42" + config.org_id = "9999" + config.strict_trace_continuation = false + end + + env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1234") + propagation_context = described_class.new(scope, env) + # org_id mismatch: baggage has 1234 but SDK effective org_id is 9999 + expect(propagation_context.incoming_trace).to eq(false) + expect(propagation_context.trace_id).not_to eq("771a43a4192642f0b136d5159a501700") + end + + it "continues when explicit org_id matches baggage org_id" do + perform_basic_setup do |config| + config.dsn = "https://key@o1234.ingest.sentry.io/42" + config.org_id = "5678" + config.strict_trace_continuation = false + end + + env = make_env(sentry_trace: sentry_trace, baggage_org_id: "5678") + propagation_context = described_class.new(scope, env) + expect(propagation_context.incoming_trace).to eq(true) + expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700") + end + end + end + describe ".extract_sentry_trace" do it "extracts valid sentry-trace without whitespace" do sentry_trace = "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1"