Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/relative-exception-filenames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'posthog-ruby': patch
'posthog-rails': patch
---

Preserve relative paths in captured exception stack frame filenames.
61 changes: 59 additions & 2 deletions lib/posthog/exception_capture.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,14 @@ def self.parse_backtrace_line(line)
lineno = match[2].to_i
method_name = match[5]

in_app = !gem_path?(file)

frame = {
'filename' => File.basename(file),
'filename' => compute_filename(file, in_app),
'abs_path' => file,
'lineno' => lineno,
'function' => method_name,
'in_app' => !gem_path?(file),
'in_app' => in_app,
'platform' => 'ruby'
}

Expand All @@ -86,6 +88,61 @@ def self.parse_backtrace_line(line)
frame
end

# @param path [String]
# @param in_app [Boolean]
# @return [String]
def self.compute_filename(path, in_app)
return path unless absolute_path?(path)

prefixes = in_app ? [rails_root, Dir.pwd] : $LOAD_PATH
strip_path_prefix(path, prefixes) || path
end

# @param path [String]
# @param prefixes [Array<String, nil>]
# @return [String, nil]
def self.strip_path_prefix(path, prefixes)
normalized_path = normalize_path(path)
normalized_prefixes = prefixes.compact.map do |prefix|
normalize_path(File.expand_path(prefix.to_s))
end

normalized_prefixes
.select { |prefix| normalized_path.start_with?("#{prefix}/") }
.max_by(&:length)
&.then { |prefix| normalized_path[(prefix.length + 1)..] }
end

# @param path [String]
# @return [String]
def self.normalize_path(path)
normalized = path.tr('\\', '/')
end_index = normalized.length
end_index -= 1 while end_index.positive? && normalized.getbyte(end_index - 1) == 47
normalized[0...end_index]
end

# @return [String, nil]
def self.rails_root
return nil unless Object.const_defined?(:Rails)

rails = Object.const_get(:Rails)
return nil unless rails.respond_to?(:root)

root = rails.root
return nil unless root

root.to_s
rescue StandardError
nil
end

# @param path [String]
# @return [Boolean]
def self.absolute_path?(path)
path.start_with?('/') || path.match?(%r{\A[a-zA-Z]:[/\\]})
end

# @param path [String]
# @return [Boolean]
def self.gem_path?(path)
Expand Down
57 changes: 48 additions & 9 deletions spec/posthog/exception_capture_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,66 @@

require 'spec_helper'

# rubocop:disable Layout/LineLength

module PostHog
describe ExceptionCapture do
describe '#parse_backtrace_line' do
it 'parses stacktrace line into frame with correct details' do
line = '/path/to/project/app/models/user.rb:42:in `validate_email\''
path = File.join(Dir.pwd, 'app/models/user.rb')
line = "#{path}:42:in `validate_email'"
frame = described_class.parse_backtrace_line(line)

expect(frame['filename']).to eq('user.rb')
expect(frame['abs_path']).to eq('/path/to/project/app/models/user.rb')
expect(frame['filename']).to eq('app/models/user.rb')
expect(frame['abs_path']).to eq(path)
expect(frame['lineno']).to eq(42)
expect(frame['function']).to eq('validate_email')
expect(frame['platform']).to eq('ruby')
expect(frame['in_app']).to be true
end

it 'uses Rails.root to make in-app filenames project relative' do
rails = Class.new do
def self.root
'/path/to/project'
end
end
stub_const('Rails', rails)

line = '/path/to/project/app/services/foo/bar.rb:42'
frame = described_class.parse_backtrace_line(line)

expect(frame['filename']).to eq('app/services/foo/bar.rb')
end

it 'keeps directory structure for files with the same basename' do
first_line = "#{File.join(Dir.pwd, 'app/services/foo/bar.rb')}:7"
second_line = "#{File.join(Dir.pwd, 'app/jobs/foo/bar.rb')}:8"

first_frame = described_class.parse_backtrace_line(first_line)
second_frame = described_class.parse_backtrace_line(second_line)

expect(first_frame['filename']).to eq('app/services/foo/bar.rb')
expect(second_frame['filename']).to eq('app/jobs/foo/bar.rb')
end

it 'identifies gem files correctly' do
gem_line = '/path/to/gems/ruby-3.0.0/lib/ruby/gems/3.0.0/gems/some_gem/lib/some_gem.rb:10:in `gem_method\''
frame = described_class.parse_backtrace_line(gem_line)

expect(frame['in_app']).to be false
end

it 'uses load path relative filenames for gem frames' do
gem_lib_path = '/path/to/gems/ruby-3.0.0/lib/ruby/gems/3.0.0/gems/some_gem/lib'
gem_line = "#{gem_lib_path}/some_gem/client.rb:10:in `gem_method'"
$LOAD_PATH.unshift(gem_lib_path)

frame = described_class.parse_backtrace_line(gem_line)

expect(frame['filename']).to eq('some_gem/client.rb')
ensure
$LOAD_PATH.delete(gem_lib_path)
end

it 'does not add context lines for non-in_app frames' do
# Use a gem-style path that points to this real file so File.exist? would be true
# but in_app should be false, so context lines should not be added
Expand Down Expand Up @@ -98,9 +134,11 @@ def test_method_that_throws

describe '#build_stacktrace' do
it 'converts backtrace array to structured frames' do
gem_lib_path = '/path/to/gems/ruby-3.0.0/lib/ruby/gems/3.0.0/gems/actionpack-7.0.0/lib'
$LOAD_PATH.unshift(gem_lib_path)
backtrace = [
'/path/to/project/app/models/user.rb:42:in `validate_email\'',
'/path/to/gems/ruby-3.0.0/lib/ruby/gems/3.0.0/gems/actionpack-7.0.0/lib/action_controller.rb:123:in `dispatch\''
"#{File.join(Dir.pwd, 'app/models/user.rb')}:42:in `validate_email'",
"#{gem_lib_path}/action_controller.rb:123:in `dispatch'"
]

stacktrace = described_class.build_stacktrace(backtrace)
Expand All @@ -109,7 +147,9 @@ def test_method_that_throws
expect(stacktrace['frames'].length).to eq(2)

expect(stacktrace['frames'][0]['filename']).to eq('action_controller.rb')
expect(stacktrace['frames'][1]['filename']).to eq('user.rb')
expect(stacktrace['frames'][1]['filename']).to eq('app/models/user.rb')
ensure
$LOAD_PATH.delete(gem_lib_path)
end
end

Expand Down Expand Up @@ -165,4 +205,3 @@ def exception_like.backtrace
end
end
end
# rubocop:enable Layout/LineLength
Loading