Skip to content

Commit cfcab4b

Browse files
giortzisgclaudesl0thentr0py
authored
feat: Implement strict trace continuation (#2872)
* feat: Implement strict trace continuation Add support for org_id extraction from DSN and strict trace continuation to control whether the SDK continues traces from third-party services. Changes: - Extract org_id from DSN host (e.g., "o123.ingest.sentry.io" -> "123") - Add `org_id` config option to explicitly set the organization ID - Add `strict_trace_continuation` boolean config option (default: false) - Propagate org_id in baggage as `sentry-org_id` - Validate incoming trace org_id against SDK org_id per decision matrix - Add comprehensive tests for all scenarios Closes #2865 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Update http_spec baggage assertion to include sentry-org_id The test DSN uses o447951.ingest.sentry.io, so the new org_id propagation correctly adds sentry-org_id=447951 to baggage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * cleanup * remove useless accessor and call to_s on org_id setter --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Neel Shah <neel.shah@sentry.io>
1 parent 3863ef6 commit cfcab4b

File tree

8 files changed

+295
-12
lines changed

8 files changed

+295
-12
lines changed

sentry-ruby/lib/sentry/baggage.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def initialize(items, mutable: true)
2525
# The presence of a Sentry item makes the baggage object immutable.
2626
#
2727
# @param header [String] The incoming Baggage header string.
28-
# @return [Baggage, nil]
28+
# @return [Baggage]
2929
def self.from_incoming_header(header)
3030
items = {}
3131
mutable = true

sentry-ruby/lib/sentry/configuration.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,24 @@ class Configuration
371371
# @return [Proc, nil]
372372
attr_reader :std_lib_logger_filter
373373

374+
# An optional organization ID. The SDK will try to extract it from the DSN in most cases
375+
# but you can provide it explicitly for self-hosted and Relay setups.
376+
# This value is used for trace propagation and for features like strict_trace_continuation.
377+
# @return [String, nil]
378+
attr_reader :org_id
379+
380+
# If set to true, the SDK will only continue a trace if the org_id of the incoming trace found in the
381+
# baggage header matches the org_id of the current Sentry client and only if BOTH are present.
382+
#
383+
# If set to false, consistency of org_id will only be enforced if both are present.
384+
# If either are missing, the trace will be continued.
385+
#
386+
# The client's organization ID is extracted from the DSN or can be set with the org_id option.
387+
# If the organization IDs do not match, the SDK will start a new trace instead of continuing the incoming one.
388+
# This is useful to prevent traces of unknown third-party services from being continued in your application.
389+
# @return [Boolean]
390+
attr_accessor :strict_trace_continuation
391+
374392
# these are not config options
375393
# @!visibility private
376394
attr_reader :errors, :gem_specs
@@ -520,6 +538,8 @@ def initialize
520538
self.trusted_proxies = []
521539
self.dsn = ENV["SENTRY_DSN"]
522540
self.capture_queue_time = true
541+
self.org_id = nil
542+
self.strict_trace_continuation = false
523543

524544
spotlight_env = ENV["SENTRY_SPOTLIGHT"]
525545
spotlight_bool = Sentry::Utils::EnvHelper.env_to_bool(spotlight_env, strict: true)
@@ -673,6 +693,16 @@ def profiler_class=(profiler_class)
673693
@profiler_class = profiler_class
674694
end
675695

696+
def org_id=(value)
697+
@org_id = value&.to_s
698+
end
699+
700+
# Returns the effective org ID, preferring the explicit config option over the DSN-parsed value.
701+
# @return [String, nil]
702+
def effective_org_id
703+
org_id || dsn&.org_id
704+
end
705+
676706
def sending_allowed?
677707
spotlight || sending_to_dsn_allowed?
678708
end

sentry-ruby/lib/sentry/dsn.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ class DSN
1111
REQUIRED_ATTRIBUTES = %w[host path public_key project_id].freeze
1212
LOCALHOST_NAMES = %w[localhost 127.0.0.1 ::1 [::1]].freeze
1313
LOCALHOST_PATTERN = /\.local(host|domain)?$/i
14+
ORG_ID_REGEX = /\Ao(\d+)\./
1415

15-
attr_reader :scheme, :secret_key, :port, *REQUIRED_ATTRIBUTES
16+
attr_reader :scheme, :secret_key, :port, :org_id, *REQUIRED_ATTRIBUTES
1617

1718
def initialize(dsn_string)
1819
@raw_value = dsn_string
@@ -31,6 +32,8 @@ def initialize(dsn_string)
3132
@host = uri.host
3233
@port = uri.port if uri.port
3334
@path = uri_path.join("/")
35+
36+
@org_id = extract_org_id_from_host
3437
end
3538

3639
def valid?
@@ -101,5 +104,14 @@ def generate_auth_header(client: nil)
101104

102105
"Sentry " + fields.map { |key, value| "#{key}=#{value}" }.join(", ")
103106
end
107+
108+
private
109+
110+
def extract_org_id_from_host
111+
return nil unless @host
112+
113+
match = ORG_ID_REGEX.match(@host)
114+
match ? match[1] : nil
115+
end
104116
end
105117
end

sentry-ruby/lib/sentry/propagation_context.rb

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,44 @@ def self.extract_sentry_trace(sentry_trace)
5353
[trace_id, parent_span_id, parent_sampled]
5454
end
5555

56+
# Determines whether we should continue an incoming trace based on org_id matching
57+
# and the strict_trace_continuation configuration option.
58+
#
59+
# @param incoming_baggage [Baggage] the baggage from the incoming request
60+
# @return [Boolean]
61+
def self.should_continue_trace?(incoming_baggage)
62+
return true unless Sentry.initialized?
63+
64+
configuration = Sentry.configuration
65+
sdk_org_id = configuration.effective_org_id
66+
baggage_org_id = incoming_baggage.items["org_id"]
67+
68+
# Mismatched org IDs always start a new trace regardless of strict mode
69+
if sdk_org_id && baggage_org_id && sdk_org_id != baggage_org_id
70+
Sentry.sdk_logger.debug(LOGGER_PROGNAME) do
71+
"Starting a new trace because org IDs don't match (incoming baggage org_id: #{baggage_org_id}, SDK org_id: #{sdk_org_id})"
72+
end
73+
74+
return false
75+
end
76+
77+
return true unless configuration.strict_trace_continuation
78+
79+
# In strict mode, both must be present and match (unless both are missing)
80+
if sdk_org_id.nil? && baggage_org_id.nil?
81+
true
82+
elsif sdk_org_id.nil? || baggage_org_id.nil?
83+
Sentry.sdk_logger.debug(LOGGER_PROGNAME) do
84+
"Starting a new trace because strict trace continuation is enabled and one org ID is missing " \
85+
"(incoming baggage org_id: #{baggage_org_id.inspect}, SDK org_id: #{sdk_org_id.inspect})"
86+
end
87+
88+
false
89+
else
90+
true
91+
end
92+
end
93+
5694
def self.extract_sample_rand_from_baggage(baggage, trace_id = nil)
5795
return unless baggage&.items
5896

@@ -96,9 +134,7 @@ def initialize(scope, env = nil)
96134
sentry_trace_data = self.class.extract_sentry_trace(sentry_trace_header)
97135

98136
if sentry_trace_data
99-
@trace_id, @parent_span_id, @parent_sampled = sentry_trace_data
100-
101-
@baggage =
137+
incoming_baggage =
102138
if baggage_header && !baggage_header.empty?
103139
Baggage.from_incoming_header(baggage_header)
104140
else
@@ -108,10 +144,13 @@ def initialize(scope, env = nil)
108144
Baggage.new({})
109145
end
110146

111-
@sample_rand = self.class.extract_sample_rand_from_baggage(@baggage, @trace_id)
112-
113-
@baggage.freeze!
114-
@incoming_trace = true
147+
if self.class.should_continue_trace?(incoming_baggage)
148+
@trace_id, @parent_span_id, @parent_sampled = sentry_trace_data
149+
@baggage = incoming_baggage
150+
@sample_rand = self.class.extract_sample_rand_from_baggage(@baggage, @trace_id)
151+
@baggage.freeze!
152+
@incoming_trace = true
153+
end
115154
end
116155
end
117156
end
@@ -162,7 +201,8 @@ def populate_head_baggage
162201
"sample_rand" => Utils::SampleRand.format(@sample_rand),
163202
"environment" => configuration.environment,
164203
"release" => configuration.release,
165-
"public_key" => configuration.dsn&.public_key
204+
"public_key" => configuration.dsn&.public_key,
205+
"org_id" => configuration.effective_org_id
166206
}
167207

168208
items.compact!

sentry-ruby/lib/sentry/transaction.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,8 @@ def populate_head_baggage
295295
"sampled" => sampled&.to_s,
296296
"environment" => configuration&.environment,
297297
"release" => configuration&.release,
298-
"public_key" => configuration&.dsn&.public_key
298+
"public_key" => configuration&.dsn&.public_key,
299+
"org_id" => configuration&.effective_org_id
299300
}
300301

301302
items["transaction"] = name unless source_low_quality?

sentry-ruby/spec/sentry/dsn_spec.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,28 @@
4444
end
4545
end
4646

47+
describe "#org_id" do
48+
it "extracts org_id from DSN host with org prefix" do
49+
dsn = described_class.new("https://key@o1234.ingest.sentry.io/42")
50+
expect(dsn.org_id).to eq("1234")
51+
end
52+
53+
it "extracts single digit org_id" do
54+
dsn = described_class.new("https://key@o1.ingest.us.sentry.io/42")
55+
expect(dsn.org_id).to eq("1")
56+
end
57+
58+
it "returns nil when host does not have org prefix" do
59+
dsn = described_class.new("http://12345:67890@sentry.localdomain:3000/sentry/42")
60+
expect(dsn.org_id).to be_nil
61+
end
62+
63+
it "returns nil for non-standard host without o prefix" do
64+
dsn = described_class.new("https://key@not_org_id.ingest.sentry.io/42")
65+
expect(dsn.org_id).to be_nil
66+
end
67+
end
68+
4769
describe "#local?" do
4870
it "returns true for localhost" do
4971
expect(described_class.new("http://12345:67890@localhost/sentry/42").local?).to eq(true)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@
156156
"sentry-sample_rand=#{Sentry::Utils::SampleRand.format(transaction.sample_rand)},"\
157157
"sentry-sampled=true,"\
158158
"sentry-environment=development,"\
159-
"sentry-public_key=foobarbaz"
159+
"sentry-public_key=foobarbaz,"\
160+
"sentry-org_id=447951"
160161
)
161162
end
162163

0 commit comments

Comments
 (0)