perf: Reduce memory allocations by ~53% during exception capture#2901
perf: Reduce memory allocations by ~53% during exception capture#2901
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
57bb1d1 to
b81088e
Compare
| copy.user = user.dup | ||
| copy.transaction_name = transaction_name.dup |
There was a problem hiding this comment.
Bug: The Scope#dup method calls .dup on @transaction_name and @transaction_source, which are nil by default, causing a TypeError during event capture in Ruby 2.7+.
Severity: CRITICAL
Suggested Fix
In Scope#dup, add a nil check before calling .dup on transaction_name and transaction_source. For example: copy.transaction_name = transaction_name.dup if transaction_name.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: sentry-ruby/lib/sentry/scope.rb#L132-L133
Potential issue: The `Scope#dup` method calls `.dup` on the `@transaction_name` and
`@transaction_source` attributes. These attributes are `nil` by default and are only
assigned a value if a transaction is explicitly configured. In Ruby versions 2.7 and
newer, calling `nil.dup` raises a `TypeError`. Because `Scope#dup` is invoked during
event capture, this will cause a crash for any captured event where a transaction name
has not been set, which is a common use case. The existing unit tests do not cover this
scenario, as they set a transaction name before testing the duplication logic.
Did we get this right? 👍 / 👎 to inform future reviews.
|
We can close this one, since we have it in the sub PRs |
| cache_key = line.object_id | ||
| cached_frame = @frame_cache&.[](cache_key) | ||
| return cached_frame if cached_frame |
There was a problem hiding this comment.
Bug: The @frame_cache uses line.object_id as a key, which can be reused by Ruby's garbage collector, leading to stale cache hits and incorrect stack frame data.
Severity: MEDIUM
Suggested Fix
The cache key for @frame_cache should be changed from line.object_id to a value that is guaranteed to be unique and stable, such as the Line object itself or a composite key derived from its attributes. This will prevent collisions caused by object_id reuse after garbage collection.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb#L90-L92
Potential issue: The `StacktraceBuilder` uses a `@frame_cache` keyed by
`line.object_id`. In Ruby, `object_id`s can be reused after an object is garbage
collected. A separate cache for `Line` objects, `@line_object_cache`, is cleared when it
reaches its size limit, allowing `Line` objects to be garbage collected. If a new `Line`
object is created and allocated the same `object_id` as a previously garbage-collected
one, the `@frame_cache` will incorrectly return the stale frame associated with the old
object. This results in incorrect stack trace data being reported.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 4 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| else | ||
| prefix_str.length + 1 | ||
| end | ||
| abs_path.byteslice(offset, abs_path.bytesize - offset) |
There was a problem hiding this comment.
byteslice uses character length instead of byte length
Medium Severity
prefix_str.length returns the character count, but byteslice expects a byte offset. For file paths containing multi-byte UTF-8 characters, the character count is less than the byte count, causing byteslice to cut into the middle of a multi-byte character and produce a corrupt filename. The old code used character-based String#[] indexing which handled this correctly.
Additional Locations (1)
| 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}") |
There was a problem hiding this comment.
in_app_pattern cache ignores project_root in key
Medium Severity
The @in_app_pattern_cache uses only app_dirs_pattern as the cache key, but the constructed Regexp embeds project_root. If project_root changes while app_dirs_pattern stays the same (e.g., re-initialization or multiple clients), the cache returns a stale pattern containing the old project_root, causing incorrect in-app classification.
| frame.set_context(linecache, context_lines) if context_lines | ||
|
|
||
| @frame_cache ||= {} | ||
| @frame_cache[cache_key] = frame if @frame_cache.size < 2048 |
There was a problem hiding this comment.
Frame cache keyed by object_id is unsafe after GC
Medium Severity
Using line.object_id as a cache key in @frame_cache is unreliable. After Line.@line_object_cache clears and old Line objects are garbage collected, Ruby may reuse their object_id values for new, unrelated Line objects. Since StacktraceBuilder is long-lived (memoized on configuration), @frame_cache can return a stale Frame with the wrong filename, line number, and source context for a completely different backtrace line.
|
|
||
| frame.pre_context = pre | ||
| frame.context_line = ctx_line | ||
| frame.post_context = post |
There was a problem hiding this comment.
Unused method set_frame_context added but never called
Low Severity
The set_frame_context method and its associated @context_cache instance variable are introduced in this PR but never called anywhere in the codebase. This is dead code that adds complexity and a caching layer (with its own memory overhead) that provides no benefit.


Summary
This PR reduces memory allocations during Sentry exception capture in Rails applications by ~53% (442k → 206k bytes, 3305 → 1538 objects).
Benchmark: Rails exception capture (MemoryProfiler, 5-iteration average)
Approach
All changes are internal optimizations — zero behavior changes. Same inputs produce identical outputs. All existing tests pass.
This PR is composed of 7 sub-PRs for easier review, grouped by risk level:
✅ Low risk (safe to merge with quick review)
match?,byteslice, single-pass iteration)Hash#fetch, direct context setting, context caching)⚡ Medium risk (caching with invalidation — review the invalidation logic)
longest_load_pathandcompute_filenameresults (class-level, $LOAD_PATH invalidation)env.dup, header name cache,hint: nil)Each sub-PR can be reviewed and merged independently into master. This base branch merges all of them together for integration testing.