Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Unreleased

- Queue time capture for Rack ([#2838](https://github.com/getsentry/sentry-ruby/pull/2838))

## 6.3.0

### Features
Expand Down
7 changes: 7 additions & 0 deletions sentry-ruby/lib/sentry/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,12 @@ class Configuration
# @return [Boolean]
attr_accessor :send_default_pii

# Capture queue time from X-Request-Start header set by reverse proxies.
# Works with any Rack app behind Nginx, HAProxy, Heroku router, etc.
# Defaults to true.
# @return [Boolean]
attr_accessor :capture_queue_time

# Allow to skip Sentry emails within rake tasks
# @return [Boolean]
attr_accessor :skip_rake_integration
Expand Down Expand Up @@ -512,6 +518,7 @@ def initialize
self.enable_backpressure_handling = false
self.trusted_proxies = []
self.dsn = ENV["SENTRY_DSN"]
self.capture_queue_time = true

spotlight_env = ENV["SENTRY_SPOTLIGHT"]
spotlight_bool = Sentry::Utils::EnvHelper.env_to_bool(spotlight_env, strict: true)
Expand Down
78 changes: 77 additions & 1 deletion sentry-ruby/lib/sentry/rack/capture_exceptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,14 @@ def start_transaction(env, scope)
}

transaction = Sentry.continue_trace(env, **options)
Sentry.start_transaction(transaction: transaction, custom_sampling_context: { env: env }, **options)
transaction = Sentry.start_transaction(transaction: transaction, custom_sampling_context: { env: env }, **options)

# attach queue time if available
if transaction && (queue_time = extract_queue_time(env))
transaction.set_data(Span::DataConventions::HTTP_QUEUE_TIME_MS, queue_time)
end

transaction
end


Expand All @@ -86,6 +93,75 @@ def finish_transaction(transaction, status_code)
def mechanism
Sentry::Mechanism.new(type: MECHANISM_TYPE, handled: false)
end

# Extracts queue time from the request environment.
# Calculates the time (in milliseconds) the request spent waiting in the
# web server queue before processing began.
#
# Subtracts puma.request_body_wait to account for time spent waiting for
# slow clients to send the request body, isolating actual queue time.
# See: https://github.com/puma/puma/blob/master/docs/architecture.md
#
# @param env [Hash] Rack env
# @return [Float, nil] queue time in milliseconds or nil
def extract_queue_time(env)
return nil unless Sentry.configuration&.capture_queue_time
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil unless Sentry.configuration&.capture_queue_time
return unless Sentry.configuration&.capture_queue_time


header_value = env["HTTP_X_REQUEST_START"]
return nil unless header_value
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil unless header_value
return unless header_value


request_start = parse_request_start_header(header_value)
return nil unless request_start
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil unless request_start
return unless request_start


total_time_ms = ((Time.now.to_f - request_start) * 1000).round(2)

# reject negative (clock skew between proxy & app server)
return nil unless total_time_ms >= 0
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil unless total_time_ms >= 0
return unless total_time_ms >= 0


puma_wait_ms = env["puma.request_body_wait"]

if puma_wait_ms && puma_wait_ms > 0
queue_time_ms = total_time_ms - puma_wait_ms
queue_time_ms >= 0 ? queue_time_ms : 0.0 # more sanity check
else
total_time_ms
end
rescue StandardError
nil
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
nil

end

# Parses X-Request-Start header value to extract a timestamp.
# Supports multiple formats:
# - Nginx: "t=1234567890.123" (seconds with decimal)
# - Heroku, HAProxy 1.9+: "t=1234567890123456" (microseconds)
# - HAProxy < 1.9: "t=1234567890" (seconds)
# - Generic: "1234567890.123" (raw timestamp)
#
# @param header_value [String] The X-Request-Start header value
# @return [Float, nil] Timestamp in seconds since epoch or nil
def parse_request_start_header(header_value)
return unless header_value

timestamp = if header_value.start_with?("t=")
header_value[2..-1].to_f
elsif header_value.match?(/\A\d+(?:\.\d+)?\z/)
header_value.to_f
else
return nil
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil
return

end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-numeric t= prefixed header values produce incorrect queue time

Medium Severity

The parse_request_start_header function validates format with a regex for raw numeric timestamps but not for t= prefixed values. When receiving a malformed header like t=invalid, "invalid".to_f silently returns 0.0 (Ruby's behavior for non-numeric strings). This results in a timestamp of Unix epoch (1970), causing the queue time calculation to return ~56 years of queue time instead of nil. The issue affects any malformed t= prefixed header value including t=, t=abc, etc.

Fix in Cursor Fix in Web


# normalize: timestamps can be in seconds, milliseconds or microseconds
# any timestamp > 10 trillion = microseconds
if timestamp > 10_000_000_000_000
timestamp / 1_000_000.0
# timestamp > 10 billion & < 10 trillion = milliseconds
elsif timestamp > 10_000_000_000
timestamp / 1_000.0
else
timestamp # assume seconds
end
rescue StandardError
end
end
end
end
3 changes: 3 additions & 0 deletions sentry-ruby/lib/sentry/span.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ module DataConventions
MESSAGING_DESTINATION_NAME = "messaging.destination.name"
MESSAGING_MESSAGE_RECEIVE_LATENCY = "messaging.message.receive.latency"
MESSAGING_MESSAGE_RETRY_COUNT = "messaging.message.retry.count"

# Time in ms the request spent in the server queue before processing began.
HTTP_QUEUE_TIME_MS = "http.server.request.time_in_queue"
end

STATUS_MAP = {
Expand Down
11 changes: 11 additions & 0 deletions sentry-ruby/spec/sentry/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,17 @@
expect { subject.before_breadcrumb = true }.to raise_error(ArgumentError, "before_breadcrumb must be callable (or nil to disable)")
end

describe "#capture_queue_time" do
it "defaults to true" do
expect(subject.capture_queue_time).to eq(true)
end

it "can be set to false" do
subject.capture_queue_time = false
expect(subject.capture_queue_time).to eq(false)
end
end

context 'being initialized with a current environment' do
before(:each) do
subject.environment = 'test'
Expand Down
87 changes: 87 additions & 0 deletions sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,93 @@ def will_be_sampled_by_sdk
end
end

describe "queue time capture" do
let(:stack) do
app = ->(_) { [200, {}, ['ok']] }
Sentry::Rack::CaptureExceptions.new(app)
end

before do
perform_basic_setup do |config|
config.traces_sample_rate = 1.0
end
end

let(:transaction) { last_sentry_event }

context "with X-Request-Start header" do
it "attaches queue time to transaction" do
timestamp = Time.now.to_f - 0.05 # 50ms ago
env["HTTP_X_REQUEST_START"] = "t=#{timestamp}"

stack.call(env)

queue_time = transaction.contexts.dig(:trace, :data, 'http.server.request.time_in_queue')
expect(queue_time).to be_within(10).of(50)
end

it "subtracts puma.request_body_wait" do
Timecop.freeze do
timestamp = Time.now.to_f - 0.1 # 100ms ago
env["HTTP_X_REQUEST_START"] = "t=#{timestamp}"
env["puma.request_body_wait"] = 40 # 40ms waiting for client

stack.call(env)

queue_time = transaction.contexts.dig(:trace, :data, 'http.server.request.time_in_queue')
expect(queue_time).to be_within(10).of(60) # 100 - 40
end
end

it "handles different timestamp formats" do
# Heroku/HAProxy microseconds format
timestamp_us = ((Time.now.to_f - 0.03) * 1_000_000).to_i
env["HTTP_X_REQUEST_START"] = "t=#{timestamp_us}"

stack.call(env)

queue_time = transaction.contexts.dig(:trace, :data, 'http.server.request.time_in_queue')
expect(queue_time).to be_within(10).of(30)
end
end

context "without X-Request-Start header" do
it "doesn't add queue time data" do
stack.call(env)

queue_time = transaction.contexts.dig(:trace, :data, 'http.server.request.time_in_queue')
expect(queue_time).to be_nil
end
end

context "with invalid header" do
it "doesn't add queue time data" do
env["HTTP_X_REQUEST_START"] = "invalid"

stack.call(env)

queue_time = transaction.contexts.dig(:trace, :data, 'http.server.request.time_in_queue')
expect(queue_time).to be_nil
end
end

context "when capture_queue_time is disabled" do
before do
Sentry.configuration.capture_queue_time = false
end

it "doesn't capture queue time" do
timestamp = Time.now.to_f - 0.05
env["HTTP_X_REQUEST_START"] = "t=#{timestamp}"

stack.call(env)

queue_time = transaction.contexts.dig(:trace, :data, 'http.server.request.time_in_queue')
expect(queue_time).to be_nil
end
end
end

describe "tracing without performance" do
let(:incoming_prop_context) { Sentry::PropagationContext.new(Sentry::Scope.new) }
let(:env) do
Expand Down