Skip to content

Commit 66ec551

Browse files
giortzisgclaude
andcommitted
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>
1 parent faa2853 commit 66ec551

6 files changed

Lines changed: 292 additions & 10 deletions

File tree

sentry-ruby/lib/sentry/configuration.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,20 @@ 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_accessor :org_id
379+
380+
# Controls whether the SDK requires matching org IDs from incoming baggage
381+
# to continue a trace. When true, the SDK will start a new trace if the org_id
382+
# from the incoming baggage does not match the SDK's own org_id, or if either
383+
# side is missing an org_id (unless both are missing).
384+
# Default is false.
385+
# @return [Boolean]
386+
attr_accessor :strict_trace_continuation
387+
374388
# these are not config options
375389
# @!visibility private
376390
attr_reader :errors, :gem_specs
@@ -520,6 +534,8 @@ def initialize
520534
self.trusted_proxies = []
521535
self.dsn = ENV["SENTRY_DSN"]
522536
self.capture_queue_time = true
537+
self.org_id = nil
538+
self.strict_trace_continuation = false
523539

524540
spotlight_env = ENV["SENTRY_SPOTLIGHT"]
525541
spotlight_bool = Sentry::Utils::EnvHelper.env_to_bool(spotlight_env, strict: true)
@@ -673,6 +689,12 @@ def profiler_class=(profiler_class)
673689
@profiler_class = profiler_class
674690
end
675691

692+
# Returns the effective org ID, preferring the explicit config option over the DSN-parsed value.
693+
# @return [String, nil]
694+
def effective_org_id
695+
org_id || dsn&.org_id
696+
end
697+
676698
def sending_allowed?
677699
spotlight || sending_to_dsn_allowed?
678700
end

sentry-ruby/lib/sentry/dsn.rb

Lines changed: 22 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,9 @@ def initialize(dsn_string)
3132
@host = uri.host
3233
@port = uri.port if uri.port
3334
@path = uri_path.join("/")
35+
36+
# Extract org ID from the host (e.g., "o123.ingest.sentry.io" -> "123")
37+
@org_id = extract_org_id_from_host
3438
end
3539

3640
def valid?
@@ -87,6 +91,14 @@ def resolved_ips_private?
8791
end
8892
end
8993

94+
# Override the org ID parsed from the DSN.
95+
# This is used when the org_id config option is set explicitly.
96+
# @param value [String]
97+
# @return [void]
98+
def org_id=(value)
99+
@org_id = value
100+
end
101+
90102
def generate_auth_header(client: nil)
91103
now = Sentry.utc_now.to_i
92104

@@ -101,5 +113,14 @@ def generate_auth_header(client: nil)
101113

102114
"Sentry " + fields.map { |key, value| "#{key}=#{value}" }.join(", ")
103115
end
116+
117+
private
118+
119+
def extract_org_id_from_host
120+
return nil unless @host
121+
122+
match = ORG_ID_REGEX.match(@host)
123+
match ? match[1] : nil
124+
end
104125
end
105126
end

sentry-ruby/lib/sentry/propagation_context.rb

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,35 @@ 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&.fetch("org_id", nil)
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+
return false
71+
end
72+
73+
# In strict mode, both must be present and match (unless both are missing)
74+
if configuration.strict_trace_continuation
75+
if sdk_org_id.nil? && baggage_org_id.nil?
76+
return true
77+
end
78+
79+
return sdk_org_id == baggage_org_id
80+
end
81+
82+
true
83+
end
84+
5685
def self.extract_sample_rand_from_baggage(baggage, trace_id = nil)
5786
return unless baggage&.items
5887

@@ -96,9 +125,7 @@ def initialize(scope, env = nil)
96125
sentry_trace_data = self.class.extract_sentry_trace(sentry_trace_header)
97126

98127
if sentry_trace_data
99-
@trace_id, @parent_span_id, @parent_sampled = sentry_trace_data
100-
101-
@baggage =
128+
incoming_baggage =
102129
if baggage_header && !baggage_header.empty?
103130
Baggage.from_incoming_header(baggage_header)
104131
else
@@ -108,10 +135,13 @@ def initialize(scope, env = nil)
108135
Baggage.new({})
109136
end
110137

111-
@sample_rand = self.class.extract_sample_rand_from_baggage(@baggage, @trace_id)
112-
113-
@baggage.freeze!
114-
@incoming_trace = true
138+
if self.class.should_continue_trace?(incoming_baggage)
139+
@trace_id, @parent_span_id, @parent_sampled = sentry_trace_data
140+
@baggage = incoming_baggage
141+
@sample_rand = self.class.extract_sample_rand_from_baggage(@baggage, @trace_id)
142+
@baggage.freeze!
143+
@incoming_trace = true
144+
end
115145
end
116146
end
117147
end
@@ -162,7 +192,8 @@ def populate_head_baggage
162192
"sample_rand" => Utils::SampleRand.format(@sample_rand),
163193
"environment" => configuration.environment,
164194
"release" => configuration.release,
165-
"public_key" => configuration.dsn&.public_key
195+
"public_key" => configuration.dsn&.public_key,
196+
"org_id" => configuration.effective_org_id
166197
}
167198

168199
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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,36 @@
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+
68+
it "can be overridden with org_id=" do
69+
dsn = described_class.new("https://key@o1234.ingest.sentry.io/42")
70+
expect(dsn.org_id).to eq("1234")
71+
72+
dsn.org_id = "9999"
73+
expect(dsn.org_id).to eq("9999")
74+
end
75+
end
76+
4777
describe "#local?" do
4878
it "returns true for localhost" do
4979
expect(described_class.new("http://12345:67890@localhost/sentry/42").local?).to eq(true)

sentry-ruby/spec/sentry/propagation_context_spec.rb

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,183 @@
133133
end
134134
end
135135

136+
describe ".should_continue_trace?" do
137+
# Decision matrix:
138+
# | Baggage org | SDK org | strict | Result |
139+
# |-------------|---------|--------|--------------|
140+
# | 1 | 1 | false | Continue |
141+
# | None | 1 | false | Continue |
142+
# | 1 | None | false | Continue |
143+
# | None | None | false | Continue |
144+
# | 1 | 2 | false | Start new |
145+
# | 1 | 1 | true | Continue |
146+
# | None | 1 | true | Start new |
147+
# | 1 | None | true | Start new |
148+
# | None | None | true | Continue |
149+
# | 1 | 2 | true | Start new |
150+
151+
let(:sentry_trace) { "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1" }
152+
153+
def make_env(sentry_trace:, baggage_org_id: nil)
154+
baggage_parts = ["sentry-trace_id=771a43a4192642f0b136d5159a501700"]
155+
baggage_parts << "sentry-org_id=#{baggage_org_id}" if baggage_org_id
156+
157+
{
158+
"sentry-trace" => sentry_trace,
159+
"baggage" => baggage_parts.join(",")
160+
}
161+
end
162+
163+
context "with strict_trace_continuation=false" do
164+
it "continues when baggage org matches SDK org" do
165+
perform_basic_setup do |config|
166+
config.dsn = "https://key@o1.ingest.sentry.io/42"
167+
config.strict_trace_continuation = false
168+
end
169+
170+
env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1")
171+
propagation_context = described_class.new(scope, env)
172+
expect(propagation_context.incoming_trace).to eq(true)
173+
expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700")
174+
end
175+
176+
it "continues when baggage has no org but SDK has org" do
177+
perform_basic_setup do |config|
178+
config.dsn = "https://key@o1.ingest.sentry.io/42"
179+
config.strict_trace_continuation = false
180+
end
181+
182+
env = make_env(sentry_trace: sentry_trace, baggage_org_id: nil)
183+
propagation_context = described_class.new(scope, env)
184+
expect(propagation_context.incoming_trace).to eq(true)
185+
expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700")
186+
end
187+
188+
it "continues when baggage has org but SDK has no org" do
189+
perform_basic_setup do |config|
190+
config.strict_trace_continuation = false
191+
end
192+
193+
env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1")
194+
propagation_context = described_class.new(scope, env)
195+
expect(propagation_context.incoming_trace).to eq(true)
196+
expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700")
197+
end
198+
199+
it "continues when neither has org" do
200+
perform_basic_setup do |config|
201+
config.strict_trace_continuation = false
202+
end
203+
204+
env = make_env(sentry_trace: sentry_trace, baggage_org_id: nil)
205+
propagation_context = described_class.new(scope, env)
206+
expect(propagation_context.incoming_trace).to eq(true)
207+
expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700")
208+
end
209+
210+
it "starts new trace when orgs mismatch" do
211+
perform_basic_setup do |config|
212+
config.dsn = "https://key@o2.ingest.sentry.io/42"
213+
config.strict_trace_continuation = false
214+
end
215+
216+
env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1")
217+
propagation_context = described_class.new(scope, env)
218+
expect(propagation_context.incoming_trace).to eq(false)
219+
expect(propagation_context.trace_id).not_to eq("771a43a4192642f0b136d5159a501700")
220+
end
221+
end
222+
223+
context "with strict_trace_continuation=true" do
224+
it "continues when baggage org matches SDK org" do
225+
perform_basic_setup do |config|
226+
config.dsn = "https://key@o1.ingest.sentry.io/42"
227+
config.strict_trace_continuation = true
228+
end
229+
230+
env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1")
231+
propagation_context = described_class.new(scope, env)
232+
expect(propagation_context.incoming_trace).to eq(true)
233+
expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700")
234+
end
235+
236+
it "starts new trace when baggage has no org but SDK has org" do
237+
perform_basic_setup do |config|
238+
config.dsn = "https://key@o1.ingest.sentry.io/42"
239+
config.strict_trace_continuation = true
240+
end
241+
242+
env = make_env(sentry_trace: sentry_trace, baggage_org_id: nil)
243+
propagation_context = described_class.new(scope, env)
244+
expect(propagation_context.incoming_trace).to eq(false)
245+
expect(propagation_context.trace_id).not_to eq("771a43a4192642f0b136d5159a501700")
246+
end
247+
248+
it "starts new trace when baggage has org but SDK has no org" do
249+
perform_basic_setup do |config|
250+
config.strict_trace_continuation = true
251+
end
252+
253+
env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1")
254+
propagation_context = described_class.new(scope, env)
255+
expect(propagation_context.incoming_trace).to eq(false)
256+
expect(propagation_context.trace_id).not_to eq("771a43a4192642f0b136d5159a501700")
257+
end
258+
259+
it "continues when neither has org" do
260+
perform_basic_setup do |config|
261+
config.strict_trace_continuation = true
262+
end
263+
264+
env = make_env(sentry_trace: sentry_trace, baggage_org_id: nil)
265+
propagation_context = described_class.new(scope, env)
266+
expect(propagation_context.incoming_trace).to eq(true)
267+
expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700")
268+
end
269+
270+
it "starts new trace when orgs mismatch" do
271+
perform_basic_setup do |config|
272+
config.dsn = "https://key@o2.ingest.sentry.io/42"
273+
config.strict_trace_continuation = true
274+
end
275+
276+
env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1")
277+
propagation_context = described_class.new(scope, env)
278+
expect(propagation_context.incoming_trace).to eq(false)
279+
expect(propagation_context.trace_id).not_to eq("771a43a4192642f0b136d5159a501700")
280+
end
281+
end
282+
283+
context "with explicit org_id config" do
284+
it "uses explicit org_id over DSN-parsed org_id" do
285+
perform_basic_setup do |config|
286+
config.dsn = "https://key@o1234.ingest.sentry.io/42"
287+
config.org_id = "9999"
288+
config.strict_trace_continuation = false
289+
end
290+
291+
env = make_env(sentry_trace: sentry_trace, baggage_org_id: "1234")
292+
propagation_context = described_class.new(scope, env)
293+
# org_id mismatch: baggage has 1234 but SDK effective org_id is 9999
294+
expect(propagation_context.incoming_trace).to eq(false)
295+
expect(propagation_context.trace_id).not_to eq("771a43a4192642f0b136d5159a501700")
296+
end
297+
298+
it "continues when explicit org_id matches baggage org_id" do
299+
perform_basic_setup do |config|
300+
config.dsn = "https://key@o1234.ingest.sentry.io/42"
301+
config.org_id = "5678"
302+
config.strict_trace_continuation = false
303+
end
304+
305+
env = make_env(sentry_trace: sentry_trace, baggage_org_id: "5678")
306+
propagation_context = described_class.new(scope, env)
307+
expect(propagation_context.incoming_trace).to eq(true)
308+
expect(propagation_context.trace_id).to eq("771a43a4192642f0b136d5159a501700")
309+
end
310+
end
311+
end
312+
136313
describe ".extract_sentry_trace" do
137314
it "extracts valid sentry-trace without whitespace" do
138315
sentry_trace = "771a43a4192642f0b136d5159a501700-7c51afd529da4a2a-1"

0 commit comments

Comments
 (0)