Skip to content

perf: Add FilenameCache to cache compute_filename results#2904

Open
HazAT wants to merge 1 commit intomasterfrom
perf/load-path-and-filename-caching
Open

perf: Add FilenameCache to cache compute_filename results#2904
HazAT wants to merge 1 commit intomasterfrom
perf/load-path-and-filename-caching

Conversation

@HazAT
Copy link
Member

@HazAT HazAT commented Mar 18, 2026

Changed below autoresearch implementation to just have one universal FilenameCache.


Below is what autoresearch initially did.

⚡ Medium risk — class-level caches with invalidation

Part of #2901 (reduce memory allocations by ~53%)

Changes

Add class-level caches to StacktraceInterface for two expensive per-frame operations that repeat with identical inputs:

longest_load_path: Previously iterated $LOAD_PATH for every frame, creating many intermediate strings. Now cached by abs_path with automatic invalidation when $LOAD_PATH.size changes (e.g. after Bundler.require).

compute_filename: Many frames share identical abs_paths (same gem files appear in every exception). Results are cached in separate in_app/not_in_app hashes keyed by abs_path only, avoiding composite array keys. Cache invalidates on project_root or $LOAD_PATH changes.

Safety

Both caches are deterministic — same inputs always produce the same filename. The caches grow proportionally to the number of unique source files seen, which is naturally bounded in any application.

HazAT added a commit that referenced this pull request Mar 18, 2026
Reduce total allocated memory from 442k to 206k bytes (-53.5%) and
objects from 3305 to 1538 (-53.5%) per Rails exception capture.

All changes are internal optimizations with zero behavior changes.

Key optimizations:
- Cache longest_load_path and compute_filename results (class-level,
  invalidated on $LOAD_PATH changes)
- Cache backtrace line parsing and Line/Frame object creation (bounded
  at 2048 entries)
- Optimize LineCache with Hash#fetch, direct context setting, and
  per-(filename, lineno) caching
- Avoid unnecessary allocations: indexed regex captures, match? instead
  of =~, byteslice, single-pass iteration in StacktraceBuilder
- RequestInterface: avoid env.dup, cache header name transforms, ASCII
  fast-path for encoding
- Scope/BreadcrumbBuffer: shallow dup instead of deep_dup where inner
  values are not mutated after duplication
- Hub#add_breadcrumb: hint default nil instead of {} to avoid empty
  hash allocation

See sub-PRs for detailed review by risk level:
- #2902 (low risk) — hot path allocation avoidance
- #2903 (low risk) — LineCache optimization
- #2904 (medium risk) — load path and filename caching
- #2905 (needs review) — backtrace parse caching
- #2906 (needs review) — Frame object caching
- #2907 (needs review) — Scope/BreadcrumbBuffer shallow dup
- #2908 (medium risk) — RequestInterface optimizations
@sl0thentr0py sl0thentr0py force-pushed the perf/load-path-and-filename-caching branch from 2a45546 to 398da01 Compare March 27, 2026 12:56
@sl0thentr0py sl0thentr0py force-pushed the perf/load-path-and-filename-caching branch from 398da01 to 33bb925 Compare March 27, 2026 15:19
@sl0thentr0py sl0thentr0py changed the title perf: cache longest_load_path and compute_filename results perf: Add FilenameCache to cache compute_filename results Mar 27, 2026
@sl0thentr0py sl0thentr0py force-pushed the perf/load-path-and-filename-caching branch 2 times, most recently from 430233c to c33e3c0 Compare March 27, 2026 15:27
Changed below autoresearch implementation to just have one universal
FilenameCache

---

Add class-level caches to StacktraceInterface for two expensive per-frame
operations that repeat with identical inputs:

longest_load_path: Previously iterated $LOAD_PATH for every frame,
creating many intermediate strings. Now cached by abs_path with automatic
invalidation when $LOAD_PATH.size changes (e.g. after Bundler.require).

compute_filename: Many frames share identical abs_paths (same gem files
appear in every exception). Results are cached in separate in_app/
not_in_app hashes keyed by abs_path only, avoiding composite array keys.
Cache invalidates on project_root or $LOAD_PATH changes.

Both caches are deterministic — same inputs always produce the same
filename. The caches grow proportionally to the number of unique source
files seen, which is naturally bounded in any application.
@sl0thentr0py sl0thentr0py force-pushed the perf/load-path-and-filename-caching branch from c33e3c0 to b86ccae Compare March 27, 2026 15:28
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Frame silently produces nil filename without cache
    • Made filename_cache a required keyword argument instead of defaulting to nil, so callers must provide a valid cache and will get an ArgumentError if they don't.
  • ✅ Fixed: Dead project_root parameter in Frame constructor
    • Removed the unused project_root positional parameter from Frame.initialize since all filename computation is now delegated to FilenameCache.

Create PR

Or push these changes by commenting:

@cursor push b9a3b54088
Preview (b9a3b54088)
diff --git a/sentry-ruby/lib/sentry/interfaces/stacktrace.rb b/sentry-ruby/lib/sentry/interfaces/stacktrace.rb
--- a/sentry-ruby/lib/sentry/interfaces/stacktrace.rb
+++ b/sentry-ruby/lib/sentry/interfaces/stacktrace.rb
@@ -27,7 +27,7 @@
       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, filename_cache: nil)
+      def initialize(line, strip_backtrace_load_path: true, filename_cache:)
         @strip_backtrace_load_path = strip_backtrace_load_path
         @filename_cache = filename_cache
 

diff --git a/sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb b/sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb
--- a/sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb
+++ b/sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb
@@ -90,7 +90,7 @@
     private
 
     def convert_parsed_line_into_frame(line)
-      frame = StacktraceInterface::Frame.new(project_root, line, strip_backtrace_load_path, filename_cache: @filename_cache)
+      frame = StacktraceInterface::Frame.new(line, strip_backtrace_load_path: strip_backtrace_load_path, filename_cache: @filename_cache)
       frame.set_context(linecache, context_lines) if context_lines
       frame
     end

diff --git a/sentry-ruby/spec/sentry/interfaces/stacktrace_spec.rb b/sentry-ruby/spec/sentry/interfaces/stacktrace_spec.rb
--- a/sentry-ruby/spec/sentry/interfaces/stacktrace_spec.rb
+++ b/sentry-ruby/spec/sentry/interfaces/stacktrace_spec.rb
@@ -15,14 +15,14 @@
     let(:filename_cache) { Sentry::FilenameCache.new(configuration.project_root) }
 
     it "initializes a Frame with the correct info from the given Backtrace::Line object" do
-      first_frame = Sentry::StacktraceInterface::Frame.new(configuration.project_root, lines.first, true, filename_cache: filename_cache)
+      first_frame = Sentry::StacktraceInterface::Frame.new(lines.first, strip_backtrace_load_path: true, filename_cache: filename_cache)
 
       expect(first_frame.filename).to match(/base.rb/)
       expect(first_frame.in_app).to eq(false)
       expect(first_frame.function).to eq("save")
       expect(first_frame.lineno).to eq(10)
 
-      second_frame = Sentry::StacktraceInterface::Frame.new(configuration.project_root, lines.last, true, filename_cache: filename_cache)
+      second_frame = Sentry::StacktraceInterface::Frame.new(lines.last, strip_backtrace_load_path: true, filename_cache: filename_cache)
 
       expect(second_frame.filename).to match(/post.rb/)
       expect(second_frame.in_app).to eq(true)
@@ -31,11 +31,11 @@
     end
 
     it "does not strip load path when strip_backtrace_load_path is false" do
-      first_frame = Sentry::StacktraceInterface::Frame.new(configuration.project_root, lines.first, false, filename_cache: filename_cache)
+      first_frame = Sentry::StacktraceInterface::Frame.new(lines.first, strip_backtrace_load_path: false, filename_cache: filename_cache)
       expect(first_frame.filename).to eq(first_frame.abs_path)
       expect(first_frame.filename).to eq(raw_lines.first.split(':').first)
 
-      second_frame = Sentry::StacktraceInterface::Frame.new(configuration.project_root, lines.last, false, filename_cache: filename_cache)
+      second_frame = Sentry::StacktraceInterface::Frame.new(lines.last, strip_backtrace_load_path: false, filename_cache: filename_cache)
       expect(second_frame.filename).to eq(second_frame.abs_path)
       expect(second_frame.filename).to eq(raw_lines.last.split(':').first)
     end

diff --git a/sentry-ruby/spec/sentry/transport_spec.rb b/sentry-ruby/spec/sentry/transport_spec.rb
--- a/sentry-ruby/spec/sentry/transport_spec.rb
+++ b/sentry-ruby/spec/sentry/transport_spec.rb
@@ -341,9 +341,8 @@
           new_stacktrace = Sentry::StacktraceInterface.new(
             frames: frame_list_size.times.map do |zero_based_index|
               Sentry::StacktraceInterface::Frame.new(
-                "/fake/path",
                 Sentry::Backtrace::Line.parse("app.rb:#{zero_based_index + 1}:in `/'", in_app_pattern),
-                true,
+                strip_backtrace_load_path: true,
                 filename_cache: Sentry::FilenameCache.new("/fake/path")
               )
             end,

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

return abs_path unless strip_backtrace_load_path

under_root = project_root && abs_path.start_with?(project_root)
prefix =
Copy link

Choose a reason for hiding this comment

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

Frame silently produces nil filename without cache

Medium Severity

compute_filename uses the safe navigation operator @filename_cache&.compute_filename(...), which returns nil when filename_cache is nil (the default). The old code always computed a proper filename from project_root and abs_path. Now any Frame created without a filename_cache gets @filename = nil instead. Since project_root is still accepted as a constructor parameter, callers have no indication that it's ignored and a filename_cache is mandatory.

Additional Locations (1)
Fix in Cursor Fix in Web

:lineno, :module, :pre_context, :post_context, :vars

def initialize(project_root, line, strip_backtrace_load_path = true)
def initialize(project_root, line, strip_backtrace_load_path = true, filename_cache: nil)
Copy link

Choose a reason for hiding this comment

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

Dead project_root parameter in Frame constructor

Low Severity

The project_root positional parameter in Frame.initialize is accepted but never stored or referenced. All filename computation now delegates to @filename_cache, which carries its own project_root. This dead parameter is misleading — callers passing different values for project_root and the cache's project_root would get silent inconsistencies.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants