Skip to content

Commit 080acef

Browse files
runephilosofGitHub Copilot
andcommitted
Fix trace_id mismatch for logs emitted before Sentry::Rails::CaptureExceptions runs
Rails::Rack::Logger's "Started ..." line (and anything else logged before CaptureExceptions runs) previously got a trace_id/span_id unrelated to the rest of the request, because CaptureExceptions is deliberately positioned after ActionDispatch::ShowExceptions (to skip transactions for static asset requests) - so it hadn't established any trace context yet when earlier middleware logged. This adds a new, minimal Sentry::Rails::CaptureContext middleware that is unshifted to the very front of the middleware stack and only establishes the propagation context (from incoming sentry-trace/baggage headers, if any) as early as possible, without touching where CaptureExceptions itself runs. To make CaptureExceptions actually reuse that early context instead of discarding/regenerating it: - Sentry::PropagationContext::ESTABLISHED_ENV_KEY is a new env flag that signals trace context was already established for this exact request. - Hub#continue_trace skips regenerating the propagation context when the flag is present. - Sentry::Rack::CaptureExceptions skips re-cloning the hub when the flag is present. - Hub#start_transaction, when not continuing a distributed trace and the flag is present (via custom_sampling_context[:env]), builds the new transaction with the already-established trace_id/sample_rand instead of generating an unrelated one. This was needed because Transaction.new otherwise always generates its own independent trace_id when not continuing an incoming trace, which would silently diverge from the propagation context even after the other two fixes. The trace_id reuse in start_transaction is intentionally scoped to only when the established flag is present, to avoid incorrectly linking together unrelated transactions that happen to share a thread (e.g. separate background jobs), which have their own propagation context by default but were never intended to share a trace_id with each other. Co-Authored-By: GitHub Copilot <noreply@example.com>
1 parent e61d825 commit 080acef

9 files changed

Lines changed: 336 additions & 3 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
module Rails
5+
# A minimal middleware that establishes Sentry's trace/scope context as early
6+
# as possible in the middleware stack.
7+
#
8+
# +Sentry::Rails::CaptureExceptions+ is intentionally positioned right after
9+
# +ActionDispatch::ShowExceptions+ so it can skip transaction creation for
10+
# static asset requests served by earlier middlewares (like +Rack::Sendfile+
11+
# and +ActionDispatch::Static+). Because of that placement, anything logged
12+
# before it runs - most notably +Rails::Rack::Logger+'s "Started ..." line -
13+
# would otherwise get a trace_id/span_id unrelated to the rest of the request.
14+
#
15+
# This middleware only establishes the propagation context (using the
16+
# incoming `sentry-trace`/`baggage` headers, if present); it does not start a
17+
# transaction or capture exceptions. +Sentry::Rack::CaptureExceptions+ detects
18+
# that context has already been established for this request (via
19+
# +PropagationContext::ESTABLISHED_ENV_KEY+) and reuses it instead of
20+
# generating a new, mismatched one.
21+
class CaptureContext
22+
def initialize(app)
23+
@app = app
24+
end
25+
26+
def call(env)
27+
return @app.call(env) unless Sentry.initialized?
28+
29+
Sentry.clone_hub_to_current_thread
30+
Sentry.get_current_scope.generate_propagation_context(env)
31+
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = true
32+
33+
@app.call(env)
34+
end
35+
end
36+
end
37+
end

sentry-rails/lib/sentry/rails/railtie.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require "sentry/rails/capture_context"
34
require "sentry/rails/capture_exceptions"
45
require "sentry/rails/rescued_exception_interceptor"
56
require "sentry/rails/backtrace_cleaner"
@@ -8,6 +9,10 @@ module Sentry
89
class Railtie < ::Rails::Railtie
910
# middlewares can't be injected after initialize
1011
initializer "sentry.use_rack_middleware" do |app|
12+
# placed as the very first middleware so that anything logged before CaptureExceptions
13+
# runs (e.g. Rails::Rack::Logger's "Started ..." line) shares the same trace context as
14+
# the rest of the request
15+
app.config.middleware.unshift Sentry::Rails::CaptureContext
1116
# placed after all the file-sending middlewares so we can avoid unnecessary transactions
1217
app.config.middleware.insert_after ActionDispatch::ShowExceptions, Sentry::Rails::CaptureExceptions
1318
# need to place as close to DebugExceptions as possible to intercept most of the exceptions, including those raised by middlewares
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe Sentry::Rails::CaptureContext do
6+
# Records the current scope's trace_id every time it's called, so specs can
7+
# compare what a piece of middleware would see at different points in the stack.
8+
class CaptureContextSpecProbe
9+
def self.captured_trace_ids
10+
@captured_trace_ids ||= []
11+
end
12+
13+
def initialize(app)
14+
@app = app
15+
end
16+
17+
def call(env)
18+
self.class.captured_trace_ids << Sentry.get_current_scope.get_trace_context[:trace_id]
19+
@app.call(env)
20+
end
21+
end
22+
23+
describe "#call" do
24+
before do
25+
make_basic_app
26+
end
27+
28+
it "establishes a propagation context and flags the env" do
29+
trace_id_in_app = nil
30+
31+
app = lambda do |env|
32+
trace_id_in_app = Sentry.get_current_scope.get_trace_context[:trace_id]
33+
[200, {}, ["ok"]]
34+
end
35+
36+
env = Rack::MockRequest.env_for("/test")
37+
described_class.new(app).call(env)
38+
39+
expect(env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY]).to eq(true)
40+
expect(trace_id_in_app).to be_a(String)
41+
end
42+
43+
it "is a no-op when Sentry is not initialized" do
44+
allow(Sentry).to receive(:initialized?).and_return(false)
45+
46+
called = false
47+
app = lambda do |env|
48+
called = true
49+
[200, {}, ["ok"]]
50+
end
51+
52+
env = Rack::MockRequest.env_for("/test")
53+
described_class.new(app).call(env)
54+
55+
expect(called).to eq(true)
56+
expect(env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY]).to be_nil
57+
end
58+
end
59+
60+
context "when composed with CaptureExceptions", type: :request do
61+
before do
62+
CaptureContextSpecProbe.captured_trace_ids.clear
63+
end
64+
65+
context "without tracing enabled" do
66+
before do
67+
make_basic_app do |config, app|
68+
app.config.middleware.insert_before(Sentry::Rails::CaptureExceptions, CaptureContextSpecProbe)
69+
app.config.middleware.insert_after(Sentry::Rails::CaptureExceptions, CaptureContextSpecProbe)
70+
end
71+
end
72+
73+
it "keeps the same trace_id before and after CaptureExceptions runs" do
74+
get "/world"
75+
76+
early_trace_id, late_trace_id = CaptureContextSpecProbe.captured_trace_ids
77+
78+
expect(early_trace_id).to be_a(String)
79+
expect(late_trace_id).to eq(early_trace_id)
80+
end
81+
end
82+
83+
context "with tracing enabled" do
84+
before do
85+
make_basic_app do |config, app|
86+
config.traces_sample_rate = 1.0
87+
app.config.middleware.insert_before(Sentry::Rails::CaptureExceptions, CaptureContextSpecProbe)
88+
app.config.middleware.insert_after(Sentry::Rails::CaptureExceptions, CaptureContextSpecProbe)
89+
end
90+
end
91+
92+
it "keeps the same trace_id from before CaptureExceptions through the started transaction" do
93+
get "/world"
94+
95+
early_trace_id, late_trace_id = CaptureContextSpecProbe.captured_trace_ids
96+
97+
expect(early_trace_id).to be_a(String)
98+
# the "late" trace_id comes from the actual transaction CaptureExceptions started
99+
expect(late_trace_id).to eq(early_trace_id)
100+
end
101+
end
102+
end
103+
end

sentry-rails/spec/sentry/rails_spec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
it "inserts middleware to a correct position" do
2424
app = Rails.application
25+
expect(app.middleware.first).to eq(Sentry::Rails::CaptureContext)
2526
index_of_executor = app.middleware.find_index { |m| m == ActionDispatch::ShowExceptions }
2627
expect(app.middleware.find_index(Sentry::Rails::CaptureExceptions)).to eq(index_of_executor + 1)
2728
index_of_debug_exceptions = app.middleware.find_index { |m| m == ActionDispatch::DebugExceptions }

sentry-ruby/lib/sentry/hub.rb

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,22 @@ def start_transaction(transaction: nil, custom_sampling_context: {}, instrumente
122122
return unless configuration.tracing_enabled?
123123
return unless instrumenter == configuration.instrumenter
124124

125+
if transaction.nil? && !options.key?(:trace_id) && trace_context_established?(custom_sampling_context)
126+
# An earlier point in this exact request/job (e.g. Sentry::Rails::CaptureContext,
127+
# via Sentry::Rack::CaptureExceptions) already established the scope's propagation
128+
# context specifically for this operation - most likely because something was
129+
# already logged/traced with it before this transaction was created. Adopt its
130+
# trace_id (and correspondingly-derived sample_rand) instead of generating an
131+
# unrelated one, so this transaction doesn't diverge from what was already
132+
# established. This is intentionally scoped to only when the established flag is
133+
# present, since the scope always has *some* propagation context by default and
134+
# blindly reusing it here would incorrectly link together unrelated transactions
135+
# (e.g. separate background jobs sharing a thread).
136+
propagation_context = current_scope.propagation_context
137+
options[:trace_id] = propagation_context.trace_id
138+
options[:sample_rand] ||= propagation_context.sample_rand
139+
end
140+
125141
transaction ||= Transaction.new(**options)
126142

127143
sampling_context = {
@@ -374,7 +390,11 @@ def get_trace_propagation_meta
374390
end
375391

376392
def continue_trace(env, **options)
377-
configure_scope { |s| s.generate_propagation_context(env) }
393+
# Don't clobber context that an earlier point in the stack already established
394+
# for this exact request/env (see PropagationContext::ESTABLISHED_ENV_KEY).
395+
unless env && env[PropagationContext::ESTABLISHED_ENV_KEY]
396+
configure_scope { |s| s.generate_propagation_context(env) }
397+
end
378398

379399
return nil unless configuration.tracing_enabled?
380400

@@ -393,6 +413,11 @@ def continue_trace(env, **options)
393413

394414
private
395415

416+
def trace_context_established?(custom_sampling_context)
417+
env = custom_sampling_context[:env]
418+
!!(env && env[PropagationContext::ESTABLISHED_ENV_KEY])
419+
end
420+
396421
def current_layer
397422
@stack.last
398423
end

sentry-ruby/lib/sentry/propagation_context.rb

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

16+
# Rack env key used to signal that trace/scope context has already been established
17+
# for the current request by an earlier point in the middleware stack (e.g. by
18+
# +Sentry::Rails::CaptureContext+, which runs ahead of +Sentry::Rack::CaptureExceptions+
19+
# so that anything logged before the latter runs still shares the request's trace_id).
20+
#
21+
# When this flag is present, +Hub#continue_trace+ and +Sentry::Rack::CaptureExceptions+
22+
# reuse the already-established context instead of regenerating/discarding it.
23+
#
24+
# +Sentry::Rack::CaptureExceptions+ deletes this key from +env+ once it's done using it,
25+
# so it only ever affects the single request it was set up for. This matters for
26+
# integrations (like Action Cable) that hold on to the same +env+ for the lifetime of a
27+
# long-running connection and reuse it across many separate operations - each of those
28+
# needs its own freshly-established context rather than perpetually reusing this one.
29+
ESTABLISHED_ENV_KEY = "sentry.trace_context_established"
30+
1631
# An uuid that can be used to identify a trace.
1732
# @return [String]
1833
attr_reader :trace_id

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require "sentry/propagation_context"
4+
35
module Sentry
46
module Rack
57
class CaptureExceptions
@@ -14,8 +16,10 @@ def initialize(app)
1416
def call(env)
1517
return @app.call(env) unless Sentry.initialized?
1618

17-
# make sure the current thread has a clean hub
18-
Sentry.clone_hub_to_current_thread
19+
# make sure the current thread has a clean hub, unless an earlier point in the
20+
# middleware stack already established one for this exact request (e.g.
21+
# Sentry::Rails::CaptureContext) - in that case reuse it instead of discarding it.
22+
Sentry.clone_hub_to_current_thread unless env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY]
1923

2024
Sentry.with_scope do |scope|
2125
Sentry.with_session_tracking do
@@ -26,6 +30,14 @@ def call(env)
2630
transaction = start_transaction(env, scope)
2731
scope.set_span(transaction) if transaction
2832

33+
# Consume the flag now that it's served its purpose (letting continue_trace/
34+
# start_transaction above reuse the context established earlier in the stack).
35+
# It must not leak into the downstream app call: some integrations (e.g. Action
36+
# Cable) hold on to this exact env for the lifetime of a long-running connection
37+
# and reuse it across many unrelated operations, each of which needs its own
38+
# freshly-established propagation context rather than perpetually reusing this one.
39+
env.delete(Sentry::PropagationContext::ESTABLISHED_ENV_KEY)
40+
2941
begin
3042
response = @app.call(env)
3143
rescue Sentry::Error

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,70 @@
9393
expect(env.key?("sentry.error_event_id")).to eq(false)
9494
end
9595

96+
context "when trace context was already established earlier in the stack" do
97+
it "does not re-clone the hub and reuses the existing propagation context" do
98+
Sentry.clone_hub_to_current_thread
99+
Sentry.get_current_scope.generate_propagation_context(env)
100+
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = true
101+
102+
established_propagation_context = Sentry.get_current_scope.propagation_context
103+
104+
expect(Sentry).not_to receive(:clone_hub_to_current_thread)
105+
106+
trace_id_in_app = nil
107+
app = lambda do |e|
108+
trace_id_in_app = Sentry.get_current_scope.get_trace_context[:trace_id]
109+
[200, {}, ['okay']]
110+
end
111+
112+
stack = Sentry::Rack::CaptureExceptions.new(app)
113+
stack.call(env)
114+
115+
expect(trace_id_in_app).to eq(established_propagation_context.trace_id)
116+
end
117+
118+
it "deletes the established flag from env so it doesn't leak into later reuses of the same env" do
119+
Sentry.clone_hub_to_current_thread
120+
Sentry.get_current_scope.generate_propagation_context(env)
121+
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = true
122+
123+
app = ->(_e) { [200, {}, ['okay']] }
124+
stack = Sentry::Rack::CaptureExceptions.new(app)
125+
stack.call(env)
126+
127+
expect(env.key?(Sentry::PropagationContext::ESTABLISHED_ENV_KEY)).to eq(false)
128+
end
129+
130+
it "does not reuse a stale established context on a later, unrelated call with the same env" do
131+
# Simulates a long-lived connection (e.g. Action Cable) that stores the handshake's
132+
# env and reuses it for many separate operations over its lifetime - only the very
133+
# first operation immediately following CaptureContext should honor the flag.
134+
Sentry.clone_hub_to_current_thread
135+
Sentry.get_current_scope.generate_propagation_context(env)
136+
env[Sentry::PropagationContext::ESTABLISHED_ENV_KEY] = true
137+
138+
app = ->(_e) { [200, {}, ['okay']] }
139+
stack = Sentry::Rack::CaptureExceptions.new(app)
140+
stack.call(env)
141+
142+
Sentry.clone_hub_to_current_thread
143+
propagation_context_before_second_call = Sentry.get_current_scope.propagation_context
144+
145+
trace_id_in_second_call = nil
146+
second_app = lambda do |e|
147+
trace_id_in_second_call = Sentry.get_current_scope.get_trace_context[:trace_id]
148+
[200, {}, ['okay']]
149+
end
150+
151+
expect(Sentry).to receive(:clone_hub_to_current_thread).and_call_original
152+
153+
second_stack = Sentry::Rack::CaptureExceptions.new(second_app)
154+
second_stack.call(env)
155+
156+
expect(trace_id_in_second_call).not_to eq(propagation_context_before_second_call.trace_id)
157+
end
158+
end
159+
96160
context "with config.include_local_variables = true" do
97161
before do
98162
perform_basic_setup do |config|

0 commit comments

Comments
 (0)