Skip to content

Commit 096916b

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 096916b

9 files changed

Lines changed: 281 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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ 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+
ESTABLISHED_ENV_KEY = "sentry.trace_context_established"
24+
1625
# An uuid that can be used to identify a trace.
1726
# @return [String]
1827
attr_reader :trace_id

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

Lines changed: 6 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

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,29 @@
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+
end
118+
96119
context "with config.include_local_variables = true" do
97120
before do
98121
perform_basic_setup do |config|

sentry-ruby/spec/sentry_spec.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,64 @@
445445
end
446446

447447
describe ".start_transaction" do
448+
describe "when not continuing an existing trace" do
449+
before do
450+
perform_basic_setup do |config|
451+
config.traces_sample_rate = 1.0
452+
end
453+
end
454+
455+
it "does not adopt the scope's propagation context when it wasn't established for this call" do
456+
propagation_context = Sentry.get_current_scope.propagation_context
457+
458+
transaction = described_class.start_transaction(name: "test", op: "test.op")
459+
460+
# each independent call gets its own, unrelated trace_id by default - only
461+
# calls that flow through an env flagged with
462+
# PropagationContext::ESTABLISHED_ENV_KEY (e.g. a Rack request that went
463+
# through Sentry::Rails::CaptureContext) adopt the scope's propagation context
464+
expect(transaction.trace_id).not_to eq(propagation_context.trace_id)
465+
end
466+
467+
context "when the scope's propagation context was established for this exact env" do
468+
let(:env) { { Sentry::PropagationContext::ESTABLISHED_ENV_KEY => true } }
469+
470+
before do
471+
Sentry.get_current_scope.generate_propagation_context(env)
472+
end
473+
474+
it "adopts the scope's propagation context trace_id and sample_rand" do
475+
propagation_context = Sentry.get_current_scope.propagation_context
476+
477+
transaction = described_class.start_transaction(
478+
name: "test", op: "test.op", custom_sampling_context: { env: env }
479+
)
480+
481+
expect(transaction.trace_id).to eq(propagation_context.trace_id)
482+
expect(transaction.sample_rand).to eq(propagation_context.sample_rand)
483+
end
484+
485+
it "does not override an explicitly provided trace_id" do
486+
transaction = described_class.start_transaction(
487+
name: "test", op: "test.op", trace_id: "a" * 32, custom_sampling_context: { env: env }
488+
)
489+
490+
expect(transaction.trace_id).to eq("a" * 32)
491+
end
492+
493+
it "does not override an explicitly provided sample_rand" do
494+
propagation_context = Sentry.get_current_scope.propagation_context
495+
496+
transaction = described_class.start_transaction(
497+
name: "test", op: "test.op", sample_rand: 0.999999, custom_sampling_context: { env: env }
498+
)
499+
500+
expect(transaction.trace_id).to eq(propagation_context.trace_id)
501+
expect(transaction.sample_rand).to eq(0.999999)
502+
end
503+
end
504+
end
505+
448506
describe "sampler example" do
449507
before do
450508
perform_basic_setup do |config|
@@ -1038,6 +1096,19 @@
10381096
propagation_context = Sentry.get_current_scope.propagation_context
10391097
expect(propagation_context.incoming_trace).to eq(false)
10401098
end
1099+
1100+
context "when trace context was already established for this env" do
1101+
let(:env) { { "HTTP_FOO" => "bar", Sentry::PropagationContext::ESTABLISHED_ENV_KEY => true } }
1102+
1103+
it "does not regenerate the scope's propagation context" do
1104+
existing_propagation_context = Sentry.get_current_scope.propagation_context
1105+
1106+
expect(Sentry.get_current_scope).not_to receive(:generate_propagation_context)
1107+
described_class.continue_trace(env)
1108+
1109+
expect(Sentry.get_current_scope.propagation_context).to eq(existing_propagation_context)
1110+
end
1111+
end
10411112
end
10421113

10431114
context "with incoming sentry trace" do

0 commit comments

Comments
 (0)