From 8741117d4308906ed7795b8fb9e96946c41f3a79 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Wed, 18 Mar 2026 15:54:48 +0100 Subject: [PATCH] perf: cache backtrace line parsing and Line object creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ Needs closer review — introduces class-level mutable caches. Add two layers of caching to Backtrace::Line.parse to avoid redundant work when the same backtrace lines appear across multiple exceptions (which is the common case in production): 1. Parse data cache: Caches the extracted (file, number, method, module_name) tuple by the raw unparsed line string. Avoids re-running the regex match and string extraction on cache hit. 2. Line object cache: Caches complete Line objects by (unparsed_line, in_app_pattern) pair. Avoids creating new Line objects entirely when the same line has been seen with the same pattern. Both caches are bounded to 2048 entries and clear entirely when the limit is reached (simple, no LRU overhead). Also cache the compiled in_app_pattern Regexp in Backtrace.parse to avoid Regexp.new on every exception capture. Safety: Line objects are effectively immutable after creation (all attributes are set in initialize and only read afterwards). The parse inputs are deterministic — same unparsed_line always produces the same parsed data. --- sentry-ruby/lib/sentry/backtrace.rb | 7 +++- sentry-ruby/lib/sentry/backtrace/line.rb | 52 ++++++++++++++++++++---- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/sentry-ruby/lib/sentry/backtrace.rb b/sentry-ruby/lib/sentry/backtrace.rb index 73a77acfd..9c7803465 100644 --- a/sentry-ruby/lib/sentry/backtrace.rb +++ b/sentry-ruby/lib/sentry/backtrace.rb @@ -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}") end lines = ruby_lines.to_a.map do |unparsed_line| diff --git a/sentry-ruby/lib/sentry/backtrace/line.rb b/sentry-ruby/lib/sentry/backtrace/line.rb index 2a9f6c96e..a30066ad8 100644 --- a/sentry-ruby/lib/sentry/backtrace/line.rb +++ b/sentry-ruby/lib/sentry/backtrace/line.rb @@ -30,21 +30,55 @@ 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) + # 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 - 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 + cached = @parse_cache[unparsed_line] + unless cached + 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) + else + java_match = unparsed_line.match(JAVA_INPUT_FORMAT) + _, module_name, method, file, number = java_match.to_a + end + cached = [file, number, method, module_name].freeze + @parse_cache.clear if @parse_cache.size >= PARSE_CACHE_LIMIT + @parse_cache[unparsed_line] = cached end - new(file, number, method, module_name, in_app_pattern) + + 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