Skip to content

Commit e2a073a

Browse files
authored
perf: Add FilenameCache to cache compute_filename results (#2904)
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.
1 parent ad8d576 commit e2a073a

File tree

5 files changed

+64
-37
lines changed

5 files changed

+64
-37
lines changed

sentry-ruby/lib/sentry/interfaces/stacktrace.rb

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -27,44 +27,24 @@ class Frame < Interface
2727
attr_accessor :abs_path, :context_line, :function, :in_app, :filename,
2828
:lineno, :module, :pre_context, :post_context, :vars
2929

30-
def initialize(project_root, line, strip_backtrace_load_path = true)
30+
def initialize(project_root, line, strip_backtrace_load_path = true, filename_cache: nil)
31+
@strip_backtrace_load_path = strip_backtrace_load_path
32+
@filename_cache = filename_cache
33+
3134
@abs_path = line.file
3235
@function = line.method if line.method
3336
@lineno = line.number
3437
@in_app = line.in_app
3538
@module = line.module_name if line.module_name
36-
@filename = compute_filename(project_root, strip_backtrace_load_path)
39+
@filename = compute_filename
3740
end
3841

3942
def to_s
4043
"#{@filename}:#{@lineno}"
4144
end
4245

43-
def compute_filename(project_root, strip_backtrace_load_path)
44-
return if abs_path.nil?
45-
return abs_path unless strip_backtrace_load_path
46-
47-
under_root = project_root && abs_path.start_with?(project_root)
48-
prefix =
49-
if under_root && in_app
50-
project_root
51-
elsif under_root
52-
longest_load_path || project_root
53-
else
54-
longest_load_path
55-
end
56-
57-
if prefix
58-
prefix_str = prefix.to_s
59-
offset = if prefix_str.end_with?(File::SEPARATOR)
60-
prefix_str.bytesize
61-
else
62-
prefix_str.bytesize + 1
63-
end
64-
abs_path.byteslice(offset, abs_path.bytesize - offset)
65-
else
66-
abs_path
67-
end
46+
def compute_filename
47+
@filename_cache&.compute_filename(abs_path, in_app, @strip_backtrace_load_path)
6848
end
6949

7050
def set_context(linecache, context_lines)
@@ -84,10 +64,6 @@ def to_h(*args)
8464
end
8565

8666
private
87-
88-
def longest_load_path
89-
$LOAD_PATH.select { |path| abs_path.start_with?(path.to_s) }.max_by(&:size)
90-
end
9167
end
9268
end
9369
end

sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require "sentry/utils/filename_cache"
4+
35
module Sentry
46
class StacktraceBuilder
57
# @return [String]
@@ -47,6 +49,7 @@ def initialize(
4749
@backtrace_cleanup_callback = backtrace_cleanup_callback
4850
@strip_backtrace_load_path = strip_backtrace_load_path
4951
@in_app_pattern = Regexp.new("^(#{project_root}/)?#{app_dirs_pattern}") if app_dirs_pattern
52+
@filename_cache = FilenameCache.new(project_root)
5053
end
5154

5255
# Generates a StacktraceInterface with the given backtrace.
@@ -87,7 +90,7 @@ def build(backtrace:, &frame_callback)
8790
private
8891

8992
def convert_parsed_line_into_frame(line)
90-
frame = StacktraceInterface::Frame.new(project_root, line, strip_backtrace_load_path)
93+
frame = StacktraceInterface::Frame.new(project_root, line, strip_backtrace_load_path, filename_cache: @filename_cache)
9194
frame.set_context(linecache, context_lines) if context_lines
9295
frame
9396
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
class FilenameCache
5+
def initialize(project_root)
6+
@project_root = project_root
7+
@load_paths = $LOAD_PATH.map(&:to_s).sort_by(&:size).reverse.freeze
8+
@cache = {}
9+
end
10+
11+
def compute_filename(abs_path, in_app, strip_backtrace_load_path)
12+
return unless abs_path
13+
return abs_path unless strip_backtrace_load_path
14+
15+
@cache.fetch(abs_path) do
16+
under_root = @project_root && abs_path.start_with?(@project_root)
17+
prefix =
18+
if under_root && in_app
19+
@project_root
20+
elsif under_root
21+
longest_load_path(abs_path) || @project_root
22+
else
23+
longest_load_path(abs_path)
24+
end
25+
26+
@cache[abs_path] = if prefix
27+
offset = if prefix.end_with?(File::SEPARATOR)
28+
prefix.bytesize
29+
else
30+
prefix.bytesize + 1
31+
end
32+
abs_path.byteslice(offset, abs_path.bytesize - offset)
33+
else
34+
abs_path
35+
end
36+
end
37+
end
38+
39+
private
40+
41+
def longest_load_path(abs_path)
42+
@load_paths.find { |path| abs_path.start_with?(path) }
43+
end
44+
end
45+
end

sentry-ruby/spec/sentry/interfaces/stacktrace_spec.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,17 @@
1212
let(:lines) do
1313
Sentry::Backtrace.parse(raw_lines, configuration.project_root, configuration.app_dirs_pattern).lines
1414
end
15+
let(:filename_cache) { Sentry::FilenameCache.new(configuration.project_root) }
1516

1617
it "initializes a Frame with the correct info from the given Backtrace::Line object" do
17-
first_frame = Sentry::StacktraceInterface::Frame.new(configuration.project_root, lines.first)
18+
first_frame = Sentry::StacktraceInterface::Frame.new(configuration.project_root, lines.first, true, filename_cache: filename_cache)
1819

1920
expect(first_frame.filename).to match(/base.rb/)
2021
expect(first_frame.in_app).to eq(false)
2122
expect(first_frame.function).to eq("save")
2223
expect(first_frame.lineno).to eq(10)
2324

24-
second_frame = Sentry::StacktraceInterface::Frame.new(configuration.project_root, lines.last)
25+
second_frame = Sentry::StacktraceInterface::Frame.new(configuration.project_root, lines.last, true, filename_cache: filename_cache)
2526

2627
expect(second_frame.filename).to match(/post.rb/)
2728
expect(second_frame.in_app).to eq(true)
@@ -30,11 +31,11 @@
3031
end
3132

3233
it "does not strip load path when strip_backtrace_load_path is false" do
33-
first_frame = Sentry::StacktraceInterface::Frame.new(configuration.project_root, lines.first, false)
34+
first_frame = Sentry::StacktraceInterface::Frame.new(configuration.project_root, lines.first, false, filename_cache: filename_cache)
3435
expect(first_frame.filename).to eq(first_frame.abs_path)
3536
expect(first_frame.filename).to eq(raw_lines.first.split(':').first)
3637

37-
second_frame = Sentry::StacktraceInterface::Frame.new(configuration.project_root, lines.last, false)
38+
second_frame = Sentry::StacktraceInterface::Frame.new(configuration.project_root, lines.last, false, filename_cache: filename_cache)
3839
expect(second_frame.filename).to eq(second_frame.abs_path)
3940
expect(second_frame.filename).to eq(raw_lines.last.split(':').first)
4041
end

sentry-ruby/spec/sentry/transport_spec.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,9 @@
342342
frames: frame_list_size.times.map do |zero_based_index|
343343
Sentry::StacktraceInterface::Frame.new(
344344
"/fake/path",
345-
Sentry::Backtrace::Line.parse("app.rb:#{zero_based_index + 1}:in `/'", in_app_pattern)
345+
Sentry::Backtrace::Line.parse("app.rb:#{zero_based_index + 1}:in `/'", in_app_pattern),
346+
true,
347+
filename_cache: Sentry::FilenameCache.new("/fake/path")
346348
)
347349
end,
348350
)

0 commit comments

Comments
 (0)