perf: Add FilenameCache to cache compute_filename results#2904
perf: Add FilenameCache to cache compute_filename results#2904
Conversation
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
2a45546 to
398da01
Compare
398da01 to
33bb925
Compare
430233c to
c33e3c0
Compare
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.
c33e3c0 to
b86ccae
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
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_rootparameter in Frame constructor- Removed the unused project_root positional parameter from Frame.initialize since all filename computation is now delegated to FilenameCache.
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 = |
There was a problem hiding this comment.
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)
| :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) |
There was a problem hiding this comment.
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.



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
StacktraceInterfacefor two expensive per-frame operations that repeat with identical inputs:longest_load_path: Previously iterated$LOAD_PATHfor every frame, creating many intermediate strings. Now cached byabs_pathwith automatic invalidation when$LOAD_PATH.sizechanges (e.g. afterBundler.require).compute_filename: Many frames share identicalabs_paths (same gem files appear in every exception). Results are cached in separatein_app/not_in_apphashes keyed byabs_pathonly, avoiding composite array keys. Cache invalidates onproject_rootor$LOAD_PATHchanges.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.