Skip to content
Closed
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
7 changes: 5 additions & 2 deletions sentry-ruby/lib/sentry/backtrace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ class Backtrace
# holder for an Array of Backtrace::Line instances
attr_reader :lines

@in_app_pattern_cache = {}

def self.parse(backtrace, project_root, app_dirs_pattern, &backtrace_cleanup_callback)
ruby_lines = backtrace.is_a?(Array) ? backtrace : backtrace.split(/\n\s*/)

ruby_lines = backtrace_cleanup_callback.call(ruby_lines) if backtrace_cleanup_callback

in_app_pattern ||= begin
Regexp.new("^(#{project_root}/)?#{app_dirs_pattern}")
cache_key = app_dirs_pattern
in_app_pattern = @in_app_pattern_cache.fetch(cache_key) do
@in_app_pattern_cache[cache_key] = Regexp.new("^(#{project_root}/)?#{app_dirs_pattern}")
Copy link

Choose a reason for hiding this comment

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

in_app_pattern cache ignores project_root in key

Medium Severity

The @in_app_pattern_cache uses only app_dirs_pattern as the cache key, but the constructed Regexp embeds project_root. If project_root changes while app_dirs_pattern stays the same (e.g., re-initialization or multiple clients), the cache returns a stale pattern containing the old project_root, causing incorrect in-app classification.

Fix in Cursor Fix in Web

end

lines = ruby_lines.to_a.map do |unparsed_line|
Expand Down
72 changes: 57 additions & 15 deletions sentry-ruby/lib/sentry/backtrace/line.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class Backtrace
# Handles backtrace parsing line by line
class Line
RB_EXTENSION = ".rb"
CLASS_EXTENSION = ".class"
# regexp (optional leading X: on windows, or JRuby9000 class-prefix)
RUBY_INPUT_FORMAT = /
^ \s* (?: [a-zA-Z]: | uri:classloader: )? ([^:]+ | <.*>):
Expand All @@ -30,21 +31,65 @@ class Line

attr_reader :in_app_pattern

# Cache parsed Line data (file, number, method, module_name) by unparsed line string.
# Same backtrace lines appear repeatedly (same code paths, same errors).
# Values are frozen arrays to avoid mutation.
# Limited to 2048 entries to prevent unbounded memory growth.
PARSE_CACHE_LIMIT = 2048
@parse_cache = {}

# Cache complete Line objects by (unparsed_line, in_app_pattern) to avoid
# re-creating identical Line objects across exceptions.
@line_object_cache = {}

# Parses a single line of a given backtrace
# @param [String] unparsed_line The raw line from +caller+ or some backtrace
# @return [Line] The parsed backtrace line
def self.parse(unparsed_line, in_app_pattern = nil)
ruby_match = unparsed_line.match(RUBY_INPUT_FORMAT)

if ruby_match
_, file, number, _, module_name, method = ruby_match.to_a
file.sub!(/\.class$/, RB_EXTENSION)
module_name = module_name
else
java_match = unparsed_line.match(JAVA_INPUT_FORMAT)
_, module_name, method, file, number = java_match.to_a
# Try full Line object cache first (avoids creating new objects entirely)
object_cache_key = unparsed_line
pattern_cache = @line_object_cache[object_cache_key]
if pattern_cache
cached_line = pattern_cache[in_app_pattern]
return cached_line if cached_line
end
new(file, number, method, module_name, in_app_pattern)

cached = @parse_cache[unparsed_line]
unless cached
ruby_match = unparsed_line.match(RUBY_INPUT_FORMAT)

if ruby_match
file = ruby_match[1]
number = ruby_match[2]
module_name = ruby_match[4]
method = ruby_match[5]
if file.end_with?(CLASS_EXTENSION)
file.sub!(/\.class$/, RB_EXTENSION)
end
else
java_match = unparsed_line.match(JAVA_INPUT_FORMAT)
if java_match
module_name = java_match[1]
method = java_match[2]
file = java_match[3]
number = java_match[4]
end
end
cached = [file, number, method, module_name].freeze
@parse_cache.clear if @parse_cache.size >= PARSE_CACHE_LIMIT
@parse_cache[unparsed_line] = cached
end

line = new(cached[0], cached[1], cached[2], cached[3], in_app_pattern)

# Cache the Line object — limited by parse cache limit
if @line_object_cache.size >= PARSE_CACHE_LIMIT
@line_object_cache.clear
end
pattern_cache = (@line_object_cache[object_cache_key] ||= {})
pattern_cache[in_app_pattern] = line

line
end

# Creates a Line from a Thread::Backtrace::Location object
Expand Down Expand Up @@ -74,12 +119,9 @@ def initialize(file, number, method, module_name, in_app_pattern)

def in_app
return false unless in_app_pattern
return false unless file

if file =~ in_app_pattern
true
else
false
end
file.match?(in_app_pattern)
end

# Reconstructs the line in a readable fashion
Expand Down
2 changes: 1 addition & 1 deletion sentry-ruby/lib/sentry/breadcrumb_buffer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def to_h
# @return [BreadcrumbBuffer]
def dup
copy = super
copy.buffer = buffer.deep_dup
copy.buffer = buffer.dup
copy
end
end
Expand Down
4 changes: 2 additions & 2 deletions sentry-ruby/lib/sentry/hub.rb
Original file line number Diff line number Diff line change
Expand Up @@ -283,12 +283,12 @@ def capture_event(event, **options, &block)
event
end

def add_breadcrumb(breadcrumb, hint: {})
def add_breadcrumb(breadcrumb, hint: nil)
return unless current_client
return unless configuration.enabled_in_current_env?

if before_breadcrumb = current_client.configuration.before_breadcrumb
breadcrumb = before_breadcrumb.call(breadcrumb, hint)
breadcrumb = before_breadcrumb.call(breadcrumb, hint || {})
end

return unless breadcrumb
Expand Down
58 changes: 39 additions & 19 deletions sentry-ruby/lib/sentry/interfaces/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ class RequestInterface < Interface
"HTTP_X_FORWARDED_FOR"
].freeze

# Cache for Rack env key → HTTP header name transformations
# e.g. "HTTP_ACCEPT_LANGUAGE" → "Accept-Language", "CONTENT_TYPE" → "Content-Type"
@header_name_cache = {}

class << self
attr_reader :header_name_cache
end

# See Sentry server default limits at
# https://github.com/getsentry/sentry/blob/master/src/sentry/conf/server.py
MAX_BODY_LIMIT = 4096 * 4
Expand Down Expand Up @@ -42,15 +50,6 @@ class RequestInterface < Interface
# @see Configuration#send_default_pii
# @see Configuration#rack_env_whitelist
def initialize(env:, send_default_pii:, rack_env_whitelist:)
env = env.dup

unless send_default_pii
# need to completely wipe out ip addresses
RequestInterface::IP_HEADERS.each do |header|
env.delete(header)
end
end

request = ::Rack::Request.new(env)

if send_default_pii
Expand All @@ -63,7 +62,7 @@ def initialize(env:, send_default_pii:, rack_env_whitelist:)
self.method = request.request_method

self.headers = filter_and_format_headers(env, send_default_pii)
self.env = filter_and_format_env(env, rack_env_whitelist)
self.env = filter_and_format_env(env, rack_env_whitelist, send_default_pii)
end

private
Expand Down Expand Up @@ -91,12 +90,22 @@ def filter_and_format_headers(env, send_default_pii)
next if is_server_protocol?(key, value, env["SERVER_PROTOCOL"])
next if is_skippable_header?(key)
next if key == "HTTP_AUTHORIZATION" && !send_default_pii
# Filter IP headers inline instead of env.dup + delete
next if !send_default_pii && IP_HEADERS.include?(key)

# Rack stores headers as HTTP_WHAT_EVER, we need What-Ever
key = key.sub(/^HTTP_/, "")
key = key.split("_").map(&:capitalize).join("-")

memo[key] = Utils::EncodingHelper.encode_to_utf_8(value.to_s)
key = self.class.header_name_cache[key] ||= begin
k = key.delete_prefix("HTTP_")
k.split("_").map(&:capitalize).join("-").freeze
end

# Fast path: ASCII strings are valid UTF-8, skip dup+force_encoding
str = value.to_s
memo[key] = if str.ascii_only?
str
else
Utils::EncodingHelper.encode_to_utf_8(str)
end
rescue StandardError => e
# Rails adds objects to the Rack env that can sometimes raise exceptions
# when `to_s` is called.
Expand All @@ -107,8 +116,11 @@ def filter_and_format_headers(env, send_default_pii)
end
end

# Regex to detect lowercase chars — match? is allocation-free (no MatchData/String)
LOWERCASE_PATTERN = /[a-z]/.freeze

def is_skippable_header?(key)
key.upcase != key || # lower-case envs aren't real http headers
key.match?(LOWERCASE_PATTERN) || # lower-case envs aren't real http headers
key == "HTTP_COOKIE" || # Cookies don't go here, they go somewhere else
!(key.start_with?("HTTP_") || CONTENT_HEADERS.include?(key))
end
Expand All @@ -119,17 +131,25 @@ def is_skippable_header?(key)
# if the request has legitimately sent a Version header themselves.
# See: https://github.com/rack/rack/blob/028438f/lib/rack/handler/cgi.rb#L29
def is_server_protocol?(key, value, protocol_version)
rack_version = Gem::Version.new(::Rack.release)
return false if rack_version >= Gem::Version.new("3.0")
return false if self.class.rack_3_or_above?

key == "HTTP_VERSION" && value == protocol_version
end

def filter_and_format_env(env, rack_env_whitelist)
def self.rack_3_or_above?
return @rack_3_or_above if defined?(@rack_3_or_above)

@rack_3_or_above = defined?(::Rack) &&
Gem::Version.new(::Rack.release) >= Gem::Version.new("3.0")
end

def filter_and_format_env(env, rack_env_whitelist, send_default_pii)
return env if rack_env_whitelist.empty?

env.select do |k, _v|
rack_env_whitelist.include? k.to_s
key = k.to_s
next false if !send_default_pii && IP_HEADERS.include?(key)
rack_env_whitelist.include?(key)
end
end
end
Expand Down
109 changes: 93 additions & 16 deletions sentry-ruby/lib/sentry/interfaces/stacktrace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,40 +23,121 @@ def inspect
private

# Not actually an interface, but I want to use the same style
# Cache for longest_load_path lookups — shared across all frames
@load_path_cache = {}
@load_path_size = nil
# Cache for compute_filename results — many frames share identical abs_paths
# Separate caches for in_app=true and in_app=false to avoid composite keys
@filename_cache_in_app = {}
@filename_cache_not_in_app = {}
@filename_project_root = nil

class << self
def check_load_path_freshness
current_size = $LOAD_PATH.size
if @load_path_size != current_size
@load_path_cache = {}
@filename_cache_in_app = {}
@filename_cache_not_in_app = {}
@load_path_size = current_size
end
end

def longest_load_path_for(abs_path)
check_load_path_freshness

@load_path_cache.fetch(abs_path) do
result = $LOAD_PATH.select { |path| abs_path.start_with?(path.to_s) }.max_by(&:size)
@load_path_cache[abs_path] = result
end
end

def cached_filename(abs_path, project_root, in_app, strip_backtrace_load_path)
return abs_path unless abs_path
return abs_path unless strip_backtrace_load_path

check_load_path_freshness

# Invalidate filename cache when project_root changes
if @filename_project_root != project_root
@filename_cache_in_app = {}
@filename_cache_not_in_app = {}
@filename_project_root = project_root
end

cache = in_app ? @filename_cache_in_app : @filename_cache_not_in_app
cache.fetch(abs_path) do
under_root = project_root && abs_path.start_with?(project_root)
prefix =
if under_root && in_app
project_root
elsif under_root
longest_load_path_for(abs_path) || project_root
else
longest_load_path_for(abs_path)
end

result = if prefix
prefix_str = prefix.to_s
offset = if prefix_str.end_with?(File::SEPARATOR)
prefix_str.length
else
prefix_str.length + 1
end
abs_path.byteslice(offset, abs_path.bytesize - offset)
Copy link

Choose a reason for hiding this comment

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

byteslice uses character length instead of byte length

Medium Severity

prefix_str.length returns the character count, but byteslice expects a byte offset. For file paths containing multi-byte UTF-8 characters, the character count is less than the byte count, causing byteslice to cut into the middle of a multi-byte character and produce a corrupt filename. The old code used character-based String#[] indexing which handled this correctly.

Additional Locations (1)
Fix in Cursor Fix in Web

else
abs_path
end

cache[abs_path] = result
end
end
end

class Frame < Interface
attr_accessor :abs_path, :context_line, :function, :in_app, :filename,
:lineno, :module, :pre_context, :post_context, :vars

def initialize(project_root, line, strip_backtrace_load_path = true)
@project_root = project_root
@strip_backtrace_load_path = strip_backtrace_load_path

@abs_path = line.file
@function = line.method if line.method
@lineno = line.number
@in_app = line.in_app
@module = line.module_name if line.module_name
@filename = compute_filename
@filename = StacktraceInterface.cached_filename(
@abs_path, project_root, @in_app, strip_backtrace_load_path
)
end

def to_s
"#{@filename}:#{@lineno}"
end

def compute_filename
def compute_filename(project_root, strip_backtrace_load_path)
return if abs_path.nil?
return abs_path unless @strip_backtrace_load_path
return abs_path unless strip_backtrace_load_path

under_root = project_root && abs_path.start_with?(project_root)
prefix =
if under_project_root? && in_app
@project_root
elsif under_project_root?
longest_load_path || @project_root
if under_root && in_app
project_root
elsif under_root
longest_load_path || project_root
else
longest_load_path
end

prefix ? abs_path[prefix.to_s.chomp(File::SEPARATOR).length + 1..-1] : abs_path
if prefix
prefix_str = prefix.to_s
offset = if prefix_str.end_with?(File::SEPARATOR)
prefix_str.length
else
prefix_str.length + 1
end
abs_path.byteslice(offset, abs_path.bytesize - offset)
else
abs_path
end
end

def set_context(linecache, context_lines)
Expand All @@ -77,12 +158,8 @@ def to_h(*args)

private

def under_project_root?
@project_root && abs_path.start_with?(@project_root)
end

def longest_load_path
$LOAD_PATH.select { |path| abs_path.start_with?(path.to_s) }.max_by(&:size)
StacktraceInterface.longest_load_path_for(abs_path)
end
end
end
Expand Down
Loading
Loading