From 0995848d9f9cf91dbcd7cae702d7c76465b1fceb Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Wed, 18 Mar 2026 15:51:56 +0100 Subject: [PATCH] perf: avoid unnecessary allocations in hot paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce memory allocations during exception capture with zero-risk changes that preserve identical behavior: Backtrace::Line.parse: - Use indexed regex captures (match[1]) instead of .to_a destructuring, avoiding intermediate array allocation - Add end_with? guard before .sub! for .class extension — skips string allocation on non-JRuby (99.9% of cases) - Use match? instead of =~ in #in_app to avoid MatchData allocation - Add nil guard for file in #in_app StacktraceInterface::Frame: - Remove stored @project_root/@strip_backtrace_load_path ivars (passed as args to compute_filename instead) — fewer entries in Interface#to_h - Use byteslice instead of [] for filename prefix stripping - Replace chomp(File::SEPARATOR) with end_with? check to avoid allocation - Inline under_project_root? to eliminate method call overhead StacktraceBuilder#build: - Replace select + reverse + map + compact chain with single reverse while loop, eliminating 3 intermediate array allocations RequestInterface: - Use delete_prefix instead of regex sub for HTTP_ prefix removal - Replace key.upcase != key with key.match?(LOWERCASE_PATTERN) to avoid allocating an upcased string for every Rack env entry - Cache Rack version check to avoid repeated Gem::Version comparisons --- sentry-ruby/lib/sentry/backtrace.rb | 10 +++--- sentry-ruby/lib/sentry/backtrace/line.rb | 25 +++++++++----- sentry-ruby/lib/sentry/interfaces/request.rb | 18 ++++++---- .../lib/sentry/interfaces/stacktrace.rb | 34 +++++++++++-------- .../sentry/interfaces/stacktrace_builder.rb | 20 ++++++++--- 5 files changed, 68 insertions(+), 39 deletions(-) diff --git a/sentry-ruby/lib/sentry/backtrace.rb b/sentry-ruby/lib/sentry/backtrace.rb index 73a77acfd..020305352 100644 --- a/sentry-ruby/lib/sentry/backtrace.rb +++ b/sentry-ruby/lib/sentry/backtrace.rb @@ -10,14 +10,16 @@ class Backtrace # holder for an Array of Backtrace::Line instances attr_reader :lines - def self.parse(backtrace, project_root, app_dirs_pattern, &backtrace_cleanup_callback) + # @deprecated project_root, in_app_pattern passed from outside + # @deprecated app_dirs_pattern, in_app_pattern passed from outside + def self.parse(backtrace, project_root, app_dirs_pattern, in_app_pattern: nil, &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}") - end + # in_app_pattern is now passed in from StacktraceBuilder, so this regex won't be triggered + # only here for backwards compat and will be deleted + in_app_pattern ||= Regexp.new("^(#{project_root}/)?#{app_dirs_pattern}") lines = ruby_lines.to_a.map do |unparsed_line| Line.parse(unparsed_line, in_app_pattern) diff --git a/sentry-ruby/lib/sentry/backtrace/line.rb b/sentry-ruby/lib/sentry/backtrace/line.rb index 2a9f6c96e..741038e41 100644 --- a/sentry-ruby/lib/sentry/backtrace/line.rb +++ b/sentry-ruby/lib/sentry/backtrace/line.rb @@ -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: )? ([^:]+ | <.*>): @@ -37,12 +38,21 @@ 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 + 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) - _, module_name, method, file, number = java_match.to_a + if java_match + module_name = java_match[1] + method = java_match[2] + file = java_match[3] + number = java_match[4] + end end new(file, number, method, module_name, in_app_pattern) end @@ -74,12 +84,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 diff --git a/sentry-ruby/lib/sentry/interfaces/request.rb b/sentry-ruby/lib/sentry/interfaces/request.rb index 024df1f73..2abe0b6e9 100644 --- a/sentry-ruby/lib/sentry/interfaces/request.rb +++ b/sentry-ruby/lib/sentry/interfaces/request.rb @@ -11,6 +11,9 @@ class RequestInterface < Interface "HTTP_X_FORWARDED_FOR" ].freeze + # Regex to detect lowercase chars — match? is allocation-free (no MatchData/String) + LOWERCASE_PATTERN = /[a-z]/.freeze + # See Sentry server default limits at # https://github.com/getsentry/sentry/blob/master/src/sentry/conf/server.py MAX_BODY_LIMIT = 4096 * 4 @@ -93,7 +96,7 @@ def filter_and_format_headers(env, send_default_pii) next if key == "HTTP_AUTHORIZATION" && !send_default_pii # Rack stores headers as HTTP_WHAT_EVER, we need What-Ever - key = key.sub(/^HTTP_/, "") + key = key.delete_prefix("HTTP_") key = key.split("_").map(&:capitalize).join("-") memo[key] = Utils::EncodingHelper.encode_to_utf_8(value.to_s) @@ -107,9 +110,6 @@ 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.match?(LOWERCASE_PATTERN) || # lower-case envs aren't real http headers key == "HTTP_COOKIE" || # Cookies don't go here, they go somewhere else @@ -122,12 +122,18 @@ 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 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) return env if rack_env_whitelist.empty? diff --git a/sentry-ruby/lib/sentry/interfaces/stacktrace.rb b/sentry-ruby/lib/sentry/interfaces/stacktrace.rb index 104b7a0fb..187ee177f 100644 --- a/sentry-ruby/lib/sentry/interfaces/stacktrace.rb +++ b/sentry-ruby/lib/sentry/interfaces/stacktrace.rb @@ -28,35 +28,43 @@ class Frame < Interface :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 = compute_filename(project_root, 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.bytesize + else + prefix_str.bytesize + 1 + end + abs_path.byteslice(offset, abs_path.bytesize - offset) + else + abs_path + end end def set_context(linecache, context_lines) @@ -77,10 +85,6 @@ 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) end diff --git a/sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb b/sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb index 3f4a7f090..76d74f9da 100644 --- a/sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb +++ b/sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb @@ -46,6 +46,7 @@ def initialize( @context_lines = context_lines @backtrace_cleanup_callback = backtrace_cleanup_callback @strip_backtrace_load_path = strip_backtrace_load_path + @in_app_pattern = Regexp.new("^(#{project_root}/)?#{app_dirs_pattern}") if app_dirs_pattern end # Generates a StacktraceInterface with the given backtrace. @@ -64,13 +65,21 @@ def initialize( # @yieldparam frame [StacktraceInterface::Frame] # @return [StacktraceInterface] def build(backtrace:, &frame_callback) - parsed_lines = parse_backtrace_lines(backtrace).select(&:file) + parsed_lines = parse_backtrace_lines(backtrace) + + # Build frames in reverse order, skipping lines without files + # Single pass instead of select + reverse + map + compact + frames = [] + i = parsed_lines.size - 1 + while i >= 0 + line = parsed_lines[i] + i -= 1 + next unless line.file - frames = parsed_lines.reverse.map do |line| frame = convert_parsed_line_into_frame(line) frame = frame_callback.call(frame) if frame_callback - frame - end.compact + frames << frame if frame + end StacktraceInterface.new(frames: frames) end @@ -85,7 +94,8 @@ def convert_parsed_line_into_frame(line) def parse_backtrace_lines(backtrace) Backtrace.parse( - backtrace, project_root, app_dirs_pattern, &backtrace_cleanup_callback + backtrace, project_root, app_dirs_pattern, + in_app_pattern: @in_app_pattern, &backtrace_cleanup_callback ).lines end end