Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions sentry-ruby/lib/sentry/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion sentry-ruby/lib/sentry/dsn.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?
Expand Down Expand Up @@ -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

Expand All @@ -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
47 changes: 39 additions & 8 deletions sentry-ruby/lib/sentry/propagation_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Contributor

@dingsdax dingsdax Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be more idiomatic imho 🤔

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)

  # Always start a new trace if both org IDs are present but don't match
  if sdk_org_id && baggage_org_id && sdk_org_id != baggage_org_id
    return false
  end

  # In non-strict mode, continue unless explicitly rejected above
  return true unless configuration.strict_trace_continuation

  # Strict mode:
  # - continue if both are missing
  # - otherwise require exact match
  return true if sdk_org_id.nil? && baggage_org_id.nil?

  sdk_org_id == baggage_org_id
end

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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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!
Expand Down
3 changes: 2 additions & 1 deletion sentry-ruby/lib/sentry/transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
30 changes: 30 additions & 0 deletions sentry-ruby/spec/sentry/dsn_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion sentry-ruby/spec/sentry/net/http_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading