Skip to content

Commit 3014c49

Browse files
committed
fix: store the propagation context in env instead of a boolean flag
The established flag lived in the request-scoped env but certified a thread-local hub, so any middleware doing `@app.call` on another thread lost the incoming trace. The env now carries the context itself, trusted only while it is still the current scope's.
1 parent 87b9ace commit 3014c49

5 files changed

Lines changed: 72 additions & 10 deletions

File tree

sentry-rails/lib/sentry/rails/capture_context.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def call(env)
1414

1515
Sentry.clone_hub_to_current_thread
1616
Sentry.get_current_scope.generate_propagation_context(env)
17-
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = true
17+
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = Sentry.get_current_scope.propagation_context
1818

1919
@app.call(env)
2020
end

sentry-rails/spec/sentry/rails/capture_context_spec.rb

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ def call(env)
2020
end
2121
end
2222

23+
# The shape used by hard-timeout and bulkhead middleware: the downstream stack
24+
# runs on a different thread than the one that entered. Permitting concurrent
25+
# loads around the join is what ActionController::Live does for the same reason.
26+
class ThreadHandoffMiddleware
27+
def initialize(app)
28+
@app = app
29+
end
30+
31+
def call(env)
32+
thread = Thread.new { @app.call(env) }
33+
ActiveSupport::Dependencies.interlock.permit_concurrent_loads { thread.value }
34+
end
35+
end
36+
2337
describe "#call" do
2438
before do
2539
make_basic_app
@@ -36,7 +50,8 @@ def call(env)
3650
env = Rack::MockRequest.env_for("/test")
3751
described_class.new(app).call(env)
3852

39-
expect(env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY]).to eq(true)
53+
expect(env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY])
54+
.to be(Sentry.get_current_scope.propagation_context)
4055
expect(trace_id_in_app).to be_a(String)
4156
end
4257

@@ -57,6 +72,29 @@ def call(env)
5772
end
5873
end
5974

75+
context "when a middleware hands the request to another thread", type: :request do
76+
let(:transport) { Sentry.get_current_client.transport }
77+
78+
let(:incoming_transaction) do
79+
Sentry::Transaction.new(op: "pageload", status: "ok", sampled: true, name: "a/path")
80+
end
81+
82+
before do
83+
make_basic_app do |config, app|
84+
config.traces_sample_rate = 1.0
85+
app.config.middleware.insert_before(Sentry::Rails::CaptureExceptions, ThreadHandoffMiddleware)
86+
end
87+
end
88+
89+
it "continues the incoming trace" do
90+
get "/world", headers: { "sentry-trace" => incoming_transaction.to_sentry_trace }
91+
92+
trace = transport.events.last.contexts[:trace]
93+
expect(trace[:trace_id]).to eq(incoming_transaction.trace_id)
94+
expect(trace[:parent_span_id]).to eq(incoming_transaction.span_id)
95+
end
96+
end
97+
6098
context "when composed with CaptureExceptions", type: :request do
6199
before do
62100
CaptureContextSpecProbe.captured_trace_ids.clear

sentry-ruby/lib/sentry/propagation_context.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ class PropagationContext
1313
"-?([01])?\\z" # sampled
1414
)
1515

16-
# Rack env key signaling that trace context was already established earlier in
17-
# the middleware stack (e.g. by +Sentry::Rails::CaptureContext+); consumed once by
18-
# +Sentry::Rack::CaptureExceptions+.
16+
# Rack env key carrying the PropagationContext established earlier in the middleware
17+
# stack (e.g. by +Sentry::Rails::CaptureContext+); consumed once by
18+
# +Sentry::Rack::CaptureExceptions+, which only trusts it when it still belongs to the
19+
# current execution context.
1920
ESTABLISHED_ENV_KEY = "sentry.trace_context_established"
2021

2122
# An uuid that can be used to identify a trace.

sentry-ruby/lib/sentry/rack/capture_exceptions.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ def initialize(app)
1616
def call(env)
1717
return @app.call(env) unless Sentry.initialized?
1818

19-
# consumed atomically, first, so it can't leak into later reuses of this env
20-
established = env.delete(Sentry::PropagationContext::ESTABLISHED_ENV_KEY)
19+
# consumed atomically, first, so it can't leak into later reuses of this env.
20+
# the env is request-scoped but the hub it certifies is not, so the context only
21+
# counts as established while it still belongs to the execution context we are on
22+
context = env.delete(Sentry::PropagationContext::ESTABLISHED_ENV_KEY)
23+
established = !context.nil? && context.equal?(Sentry.get_current_scope&.propagation_context)
2124

2225
# make sure the current thread has a clean hub, unless it was already established
2326
Sentry.clone_hub_to_current_thread unless established

sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
it "does not re-clone the hub and reuses the existing propagation context" do
9898
Sentry.clone_hub_to_current_thread
9999
Sentry.get_current_scope.generate_propagation_context(env)
100-
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = true
100+
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = Sentry.get_current_scope.propagation_context
101101

102102
established_propagation_context = Sentry.get_current_scope.propagation_context
103103

@@ -118,7 +118,7 @@
118118
it "deletes the established flag from env so it doesn't leak into later reuses of the same env" do
119119
Sentry.clone_hub_to_current_thread
120120
Sentry.get_current_scope.generate_propagation_context(env)
121-
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = true
121+
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = Sentry.get_current_scope.propagation_context
122122

123123
app = ->(_e) { [200, {}, ['okay']] }
124124
stack = Sentry::Rack::CaptureExceptions.new(app)
@@ -127,13 +127,33 @@
127127
expect(env.key?(Sentry::PropagationContext::ESTABLISHED_ENV_KEY)).to eq(false)
128128
end
129129

130+
it "honors the incoming trace when the established context belongs to another execution context" do
131+
external_transaction = Sentry::Transaction.new(op: "pageload", status: "ok", sampled: true, name: "a/path")
132+
env["HTTP_SENTRY_TRACE"] = external_transaction.to_sentry_trace
133+
134+
Sentry.clone_hub_to_current_thread
135+
Sentry.get_current_scope.generate_propagation_context(env)
136+
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = Sentry.get_current_scope.propagation_context
137+
138+
trace_id_in_app = nil
139+
app = lambda do |_e|
140+
trace_id_in_app = Sentry.get_current_scope.get_trace_context[:trace_id]
141+
[200, {}, ['okay']]
142+
end
143+
144+
stack = Sentry::Rack::CaptureExceptions.new(app)
145+
Thread.new { stack.call(env) }.join
146+
147+
expect(trace_id_in_app).to eq(external_transaction.trace_id)
148+
end
149+
130150
it "does not reuse a stale established context on a later, unrelated call with the same env" do
131151
# Simulates a long-lived connection (e.g. Action Cable) that stores the handshake's
132152
# env and reuses it for many separate operations over its lifetime - only the very
133153
# first operation immediately following CaptureContext should honor the flag.
134154
Sentry.clone_hub_to_current_thread
135155
Sentry.get_current_scope.generate_propagation_context(env)
136-
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = true
156+
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = Sentry.get_current_scope.propagation_context
137157

138158
app = ->(_e) { [200, {}, ['okay']] }
139159
stack = Sentry::Rack::CaptureExceptions.new(app)

0 commit comments

Comments
 (0)