From f2d852a602cdc7abe1e9f604303d783755f26c63 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 3 Jul 2026 11:28:02 +0200 Subject: [PATCH 1/2] fix: Preserve relative exception filenames --- .changeset/relative-exception-filenames.md | 5 ++ lib/posthog/exception_capture.rb | 55 ++++++++++++++++++++- spec/posthog/exception_capture_spec.rb | 57 ++++++++++++++++++---- 3 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 .changeset/relative-exception-filenames.md diff --git a/.changeset/relative-exception-filenames.md b/.changeset/relative-exception-filenames.md new file mode 100644 index 00000000..36c1632d --- /dev/null +++ b/.changeset/relative-exception-filenames.md @@ -0,0 +1,5 @@ +--- +'posthog-ruby': patch +--- + +Preserve relative paths in captured exception stack frame filenames. diff --git a/lib/posthog/exception_capture.rb b/lib/posthog/exception_capture.rb index 8249b5bc..7842131b 100644 --- a/lib/posthog/exception_capture.rb +++ b/lib/posthog/exception_capture.rb @@ -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' } @@ -86,6 +88,55 @@ 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] + # @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) + path.tr('\\', '/').sub(%r{/+\z}, '') + 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) && rails.root + + rails.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) diff --git a/spec/posthog/exception_capture_spec.rb b/spec/posthog/exception_capture_spec.rb index 8136facb..099acc6b 100644 --- a/spec/posthog/exception_capture_spec.rb +++ b/spec/posthog/exception_capture_spec.rb @@ -2,23 +2,47 @@ 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) @@ -26,6 +50,18 @@ module PostHog 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 @@ -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) @@ -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 @@ -165,4 +205,3 @@ def exception_like.backtrace end end end -# rubocop:enable Layout/LineLength From 655c3e56ded617f80e75fcd3606cc9afc620cb9d Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 3 Jul 2026 11:37:01 +0200 Subject: [PATCH 2/2] fix: Address exception filename review feedback --- .changeset/relative-exception-filenames.md | 1 + lib/posthog/exception_capture.rb | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.changeset/relative-exception-filenames.md b/.changeset/relative-exception-filenames.md index 36c1632d..f83cbb75 100644 --- a/.changeset/relative-exception-filenames.md +++ b/.changeset/relative-exception-filenames.md @@ -1,5 +1,6 @@ --- 'posthog-ruby': patch +'posthog-rails': patch --- Preserve relative paths in captured exception stack frame filenames. diff --git a/lib/posthog/exception_capture.rb b/lib/posthog/exception_capture.rb index 7842131b..7dcfb84b 100644 --- a/lib/posthog/exception_capture.rb +++ b/lib/posthog/exception_capture.rb @@ -116,7 +116,10 @@ def self.strip_path_prefix(path, prefixes) # @param path [String] # @return [String] def self.normalize_path(path) - path.tr('\\', '/').sub(%r{/+\z}, '') + 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] @@ -124,9 +127,12 @@ def self.rails_root return nil unless Object.const_defined?(:Rails) rails = Object.const_get(:Rails) - return nil unless rails.respond_to?(:root) && rails.root + return nil unless rails.respond_to?(:root) - rails.root.to_s + root = rails.root + return nil unless root + + root.to_s rescue StandardError nil end