From 209a4ec113b96c2e4e1e7714de3c2af8cd53f030 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 2 Jul 2026 20:59:25 +0200 Subject: [PATCH 1/5] feat: walk exception cause chains --- .changeset/eager-owls-chain.md | 9 ++ lib/posthog/client.rb | 11 +- lib/posthog/exception_capture.rb | 59 +++++++- posthog-rails/lib/posthog/rails/active_job.rb | 7 +- .../lib/posthog/rails/capture_exceptions.rb | 7 +- .../lib/posthog/rails/error_subscriber.rb | 7 +- public_api_snapshot.txt | 2 +- spec/posthog/client_spec.rb | 37 +++++ spec/posthog/exception_capture_spec.rb | 139 ++++++++++++++++++ .../posthog/rails/exception_mechanism_spec.rb | 127 ++++++++++++++++ 10 files changed, 390 insertions(+), 15 deletions(-) create mode 100644 .changeset/eager-owls-chain.md create mode 100644 spec/posthog/rails/exception_mechanism_spec.rb diff --git a/.changeset/eager-owls-chain.md b/.changeset/eager-owls-chain.md new file mode 100644 index 00000000..2fb551c6 --- /dev/null +++ b/.changeset/eager-owls-chain.md @@ -0,0 +1,9 @@ +--- +"posthog-ruby": minor +"posthog-rails": minor +--- + +Improve error tracking capture signals: + +- `capture_exception` now walks the full `exception.cause` chain (outermost-first, cycle-safe, capped at 50) instead of reporting only the outermost exception; chained causes are tagged with a `chained` mechanism and parent linkage. +- Exception mechanisms now reflect the capture source: manual captures stay `generic`/`handled: true`, while the Rails middleware, `Rails.error` subscriber, and ActiveJob integrations tag captures as `rails`/`rails_error_reporter`/`active_job` with the correct `handled` flag. `capture_exception` accepts a `mechanism:` keyword. diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 8f49169f..f39f4904 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -311,15 +311,18 @@ def capture(attrs) # @param flags [PostHog::FeatureFlagEvaluations, nil] A snapshot returned by {#evaluate_flags}. # Forwarded to the inner {#capture} call so the captured `$exception` event carries the # same `$feature/` and `$active_feature_flags` properties as the snapshot. + # @param mechanism [Hash, nil] How the exception was captured, e.g. + # `{ 'type' => 'rails', 'handled' => false }` for automatic integrations. + # Defaults to `{ 'type' => 'generic', 'handled' => true }` for manual captures. # @return [Boolean, nil] Whether the exception event was queued or sent, or nil if the input could not be parsed. - def capture_exception(exception, distinct_id = nil, additional_properties = {}, flags: nil) + def capture_exception(exception, distinct_id = nil, additional_properties = {}, flags: nil, mechanism: nil) return false if @disabled - exception_info = ExceptionCapture.build_parsed_exception(exception) + exception_list = ExceptionCapture.build_exception_list(exception, mechanism: mechanism) - return if exception_info.nil? + return if exception_list.nil? - properties = { '$exception_list' => [exception_info] } + properties = { '$exception_list' => exception_list } properties.merge!(additional_properties) if additional_properties && !additional_properties.empty? event_data = { diff --git a/lib/posthog/exception_capture.rb b/lib/posthog/exception_capture.rb index 8249b5bc..5430444c 100644 --- a/lib/posthog/exception_capture.rb +++ b/lib/posthog/exception_capture.rb @@ -22,27 +22,72 @@ module ExceptionCapture (?: :in\s('|`)(?:([\w:]+)\#)?([^']+)')?$ /x + # Maximum number of exceptions extracted from a single `cause` chain. + MAX_CHAINED_EXCEPTIONS = 50 + + DEFAULT_MECHANISM = { 'type' => 'generic', 'handled' => true }.freeze + + # Builds the `$exception_list` payload for an exception, walking its + # `cause` chain outermost-first (wrapper first, root cause last). + # + # @param value [Exception, String, Object] Exception input to parse. + # @param mechanism [Hash, nil] Mechanism applied to the outermost exception, + # e.g. `{ 'type' => 'rails', 'handled' => false }`. Chained causes are + # tagged with `{ 'type' => 'chained', ... }` and parent linkage. + # @return [Array, nil] Parsed exception payloads, or nil when the input is unsupported. + def self.build_exception_list(value, mechanism: nil) + root_mechanism = DEFAULT_MECHANISM.merge(mechanism || {}) + + exceptions = [] + seen = [] + current = value + + while current && exceptions.length < MAX_CHAINED_EXCEPTIONS && seen.none? { |e| e.equal?(current) } + parsed = build_parsed_exception(current, mechanism: chain_mechanism(root_mechanism, exceptions.length)) + break if parsed.nil? + + exceptions << parsed + seen << current + current = current.respond_to?(:cause) ? current.cause : nil + end + + exceptions.empty? ? nil : exceptions + end + + # @param root_mechanism [Hash] Mechanism of the outermost exception. + # @param exception_id [Integer] Zero-based position in the cause chain. + # @return [Hash] + def self.chain_mechanism(root_mechanism, exception_id) + mechanism = root_mechanism.merge('exception_id' => exception_id) + return mechanism if exception_id.zero? + + mechanism.merge( + 'type' => 'chained', + 'source' => 'cause', + 'parent_id' => exception_id - 1 + ) + end + # @param value [Exception, String, Object] Exception input to parse. + # @param mechanism [Hash, nil] Mechanism describing how the exception was captured. # @return [Hash, nil] Parsed exception payload, or nil when the input is unsupported. - def self.build_parsed_exception(value) + def self.build_parsed_exception(value, mechanism: nil) title, message, backtrace = coerce_exception_input(value) return nil if title.nil? - build_single_exception_from_data(title, message, backtrace) + build_single_exception_from_data(title, message, backtrace, mechanism: mechanism) end # @param title [String] # @param message [String, nil] # @param backtrace [Array, nil] + # @param mechanism [Hash, nil] # @return [Hash] - def self.build_single_exception_from_data(title, message, backtrace) + def self.build_single_exception_from_data(title, message, backtrace, mechanism: nil) { 'type' => title, 'value' => message || '', - 'mechanism' => { - 'type' => 'generic', - 'handled' => true - }, + 'mechanism' => DEFAULT_MECHANISM.merge(mechanism || {}), 'stacktrace' => build_stacktrace(backtrace) } end diff --git a/posthog-rails/lib/posthog/rails/active_job.rb b/posthog-rails/lib/posthog/rails/active_job.rb index 55372c7a..a7842f88 100644 --- a/posthog-rails/lib/posthog/rails/active_job.rb +++ b/posthog-rails/lib/posthog/rails/active_job.rb @@ -64,7 +64,12 @@ def capture_job_exception(exception) # Add serialized job arguments (be careful with sensitive data) properties['$job_arguments'] = sanitize_job_arguments(arguments) if arguments.present? - PostHog.capture_exception(exception, distinct_id, properties) + PostHog.capture_exception( + exception, + distinct_id, + properties, + mechanism: { 'type' => 'active_job', 'handled' => false } + ) rescue StandardError => e # Don't let PostHog errors break job processing PostHog::Logging.logger.error("Failed to capture job exception: #{e.message}") diff --git a/posthog-rails/lib/posthog/rails/capture_exceptions.rb b/posthog-rails/lib/posthog/rails/capture_exceptions.rb index b1640026..bc92bf6e 100644 --- a/posthog-rails/lib/posthog/rails/capture_exceptions.rb +++ b/posthog-rails/lib/posthog/rails/capture_exceptions.rb @@ -60,7 +60,12 @@ def capture_exception(exception, env) distinct_id = extract_distinct_id(env) additional_properties = build_properties(request, env) - PostHog.capture_exception(exception, distinct_id, additional_properties) + PostHog.capture_exception( + exception, + distinct_id, + additional_properties, + mechanism: { 'type' => 'rails', 'handled' => false } + ) rescue StandardError => e PostHog::Logging.logger.error("Failed to capture exception: #{e.message}") PostHog::Logging.logger.error("Backtrace: #{e.backtrace&.first(5)&.join("\n")}") diff --git a/posthog-rails/lib/posthog/rails/error_subscriber.rb b/posthog-rails/lib/posthog/rails/error_subscriber.rb index dc7c95c5..e402bcbc 100644 --- a/posthog-rails/lib/posthog/rails/error_subscriber.rb +++ b/posthog-rails/lib/posthog/rails/error_subscriber.rb @@ -41,7 +41,12 @@ def report(error, handled:, severity:, context:, source: nil) end end - PostHog.capture_exception(error, distinct_id, properties) + PostHog.capture_exception( + error, + distinct_id, + properties, + mechanism: { 'type' => 'rails_error_reporter', 'handled' => handled } + ) rescue StandardError => e PostHog::Logging.logger.error("Failed to report error via subscriber: #{e.message}") PostHog::Logging.logger.error("Backtrace: #{e.backtrace&.first(5)&.join("\n")}") diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 2d65f9d3..cf3b048e 100644 --- a/public_api_snapshot.txt +++ b/public_api_snapshot.txt @@ -7,7 +7,7 @@ module PostHog class PostHog::Client instance_method PostHog::Client#alias(attrs) instance_method PostHog::Client#capture(attrs) -instance_method PostHog::Client#capture_exception(exception, distinct_id = ..., additional_properties = ..., flags: ...) +instance_method PostHog::Client#capture_exception(exception, distinct_id = ..., additional_properties = ..., flags: ..., mechanism: ...) instance_method PostHog::Client#clear() instance_method PostHog::Client#dequeue_last_message() instance_method PostHog::Client#enabled?() diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index 77a5d2e0..c511268f 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -1800,6 +1800,43 @@ def run expect(message[:properties]['request_id']).to eq('req-123') end + it 'captures the full cause chain outermost-first' do + begin + begin + raise ArgumentError, 'Root cause' + rescue ArgumentError + raise StandardError, 'Wrapper error' + end + rescue StandardError => e + client.capture_exception(e, 'user-123') + end + + message = client.dequeue_last_message + exception_list = message[:properties]['$exception_list'] + + expect(exception_list.length).to eq(2) + expect(exception_list[0]['type']).to eq('StandardError') + expect(exception_list[0]['mechanism']['type']).to eq('generic') + expect(exception_list[0]['mechanism']['handled']).to be true + expect(exception_list[1]['type']).to eq('ArgumentError') + expect(exception_list[1]['mechanism']['type']).to eq('chained') + expect(exception_list[1]['mechanism']['parent_id']).to eq(0) + end + + it 'applies a custom mechanism' do + begin + raise StandardError, 'Test exception' + rescue StandardError => e + client.capture_exception(e, 'user-123', {}, mechanism: { 'type' => 'rails', 'handled' => false }) + end + + message = client.dequeue_last_message + mechanism = message[:properties]['$exception_list'].first['mechanism'] + + expect(mechanism['type']).to eq('rails') + expect(mechanism['handled']).to be false + end + it 'generates UUID as distinct_id when none was provided' do begin raise StandardError, 'Test error' diff --git a/spec/posthog/exception_capture_spec.rb b/spec/posthog/exception_capture_spec.rb index 8136facb..5f51da44 100644 --- a/spec/posthog/exception_capture_spec.rb +++ b/spec/posthog/exception_capture_spec.rb @@ -162,6 +162,145 @@ def exception_like.backtrace expect(described_class.build_parsed_exception(123)).to be_nil expect(described_class.build_parsed_exception(nil)).to be_nil end + + it 'applies a custom mechanism' do + exception_info = described_class.build_parsed_exception( + 'Simple error', + mechanism: { 'type' => 'rails', 'handled' => false } + ) + + expect(exception_info['mechanism']['type']).to eq('rails') + expect(exception_info['mechanism']['handled']).to be false + end + end + + describe '#build_exception_list' do + # Exception-like object with a configurable cause, used to build chains + # without raising for real. + let(:chainable_class) do + Class.new do + attr_accessor :cause + + def initialize(message) + @message = message + end + + attr_reader :message + + def backtrace + nil + end + end + end + + def raise_chained + begin + raise ArgumentError, 'Root cause' + rescue ArgumentError + raise 'Middle error' + end + rescue RuntimeError + raise StandardError, 'Wrapper error' + end + + it 'builds a single-element list for an exception without a cause' do + raise StandardError, 'Test exception' + rescue StandardError => e + exception_list = described_class.build_exception_list(e) + + expect(exception_list.length).to eq(1) + expect(exception_list.first['type']).to eq('StandardError') + expect(exception_list.first['mechanism']).to eq( + 'type' => 'generic', + 'handled' => true, + 'exception_id' => 0 + ) + end + + it 'walks the cause chain outermost-first' do + raise_chained + rescue StandardError => e + exception_list = described_class.build_exception_list(e) + + expect(exception_list.map { |entry| entry['type'] }).to eq(%w[StandardError RuntimeError ArgumentError]) + expect(exception_list.map { |entry| entry['value'] }).to eq(['Wrapper error', 'Middle error', 'Root cause']) + end + + it 'tags chained causes with a chained mechanism and parent linkage' do + raise_chained + rescue StandardError => e + exception_list = described_class.build_exception_list(e) + + expect(exception_list[0]['mechanism']).to eq( + 'type' => 'generic', + 'handled' => true, + 'exception_id' => 0 + ) + expect(exception_list[1]['mechanism']).to eq( + 'type' => 'chained', + 'handled' => true, + 'source' => 'cause', + 'exception_id' => 1, + 'parent_id' => 0 + ) + expect(exception_list[2]['mechanism']).to eq( + 'type' => 'chained', + 'handled' => true, + 'source' => 'cause', + 'exception_id' => 2, + 'parent_id' => 1 + ) + end + + it 'threads a custom mechanism through the chain' do + raise_chained + rescue StandardError => e + exception_list = described_class.build_exception_list(e, mechanism: { 'type' => 'rails', 'handled' => false }) + + expect(exception_list[0]['mechanism']['type']).to eq('rails') + expect(exception_list[0]['mechanism']['handled']).to be false + expect(exception_list[1]['mechanism']['type']).to eq('chained') + expect(exception_list[1]['mechanism']['handled']).to be false + end + + it 'guards against cycles in the cause chain' do + first = chainable_class.new('first') + second = chainable_class.new('second') + first.cause = second + second.cause = first + + exception_list = described_class.build_exception_list(first) + + expect(exception_list.map { |entry| entry['value'] }).to eq(%w[first second]) + end + + it 'caps the cause chain depth' do + outermost = chainable_class.new('error 0') + current = outermost + 1.upto(59) do |i| + cause = chainable_class.new("error #{i}") + current.cause = cause + current = cause + end + + exception_list = described_class.build_exception_list(outermost) + + expect(exception_list.length).to eq(described_class::MAX_CHAINED_EXCEPTIONS) + expect(exception_list.last['value']).to eq('error 49') + end + + it 'builds a single-element list for strings' do + exception_list = described_class.build_exception_list('Simple error') + + expect(exception_list.length).to eq(1) + expect(exception_list.first['value']).to eq('Simple error') + end + + it 'returns nil for invalid input types' do + expect(described_class.build_exception_list({ invalid: 'object' })).to be_nil + expect(described_class.build_exception_list(123)).to be_nil + expect(described_class.build_exception_list(nil)).to be_nil + end end end end diff --git a/spec/posthog/rails/exception_mechanism_spec.rb b/spec/posthog/rails/exception_mechanism_spec.rb new file mode 100644 index 00000000..19f42ca6 --- /dev/null +++ b/spec/posthog/rails/exception_mechanism_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rails' +require 'rails/railtie' +require 'action_dispatch' +require 'rack/mock' + +$LOAD_PATH.unshift File.expand_path('../../../posthog-rails/lib', __dir__) + +require 'posthog/rails' + +RSpec.describe 'automatic exception capture mechanisms' do + around do |example| + previous_config = PostHog::Rails.config + PostHog::Rails.config = PostHog::Rails::Configuration.new + PostHog::Rails.config.auto_capture_exceptions = true + example.run + ensure + PostHog::Rails.config = previous_config + end + + before do + allow(PostHog).to receive(:capture_exception) + end + + describe PostHog::Rails::CaptureExceptions do + it 'tags middleware captures as unhandled rails exceptions' do + app = ->(_env) { raise StandardError, 'boom' } + middleware = described_class.new(app) + env = Rack::MockRequest.env_for('/api/test') + + expect { middleware.call(env) }.to raise_error(StandardError, 'boom') + + expect(PostHog).to have_received(:capture_exception).with( + an_instance_of(StandardError), + anything, + an_instance_of(Hash), + mechanism: { 'type' => 'rails', 'handled' => false } + ) + end + end + + describe PostHog::Rails::ErrorSubscriber do + it 'forwards the handled flag reported by Rails' do + described_class.new.report( + StandardError.new('boom'), + handled: true, + severity: :warning, + context: {} + ) + + expect(PostHog).to have_received(:capture_exception).with( + an_instance_of(StandardError), + anything, + an_instance_of(Hash), + mechanism: { 'type' => 'rails_error_reporter', 'handled' => true } + ) + end + + it 'tags unhandled reports as unhandled' do + described_class.new.report( + StandardError.new('boom'), + handled: false, + severity: :error, + context: {} + ) + + expect(PostHog).to have_received(:capture_exception).with( + an_instance_of(StandardError), + anything, + an_instance_of(Hash), + mechanism: { 'type' => 'rails_error_reporter', 'handled' => false } + ) + end + end + + describe PostHog::Rails::ActiveJobExtensions do + let(:job_class) do + extensions = described_class + Class.new do + prepend extensions + + def self.name + 'FakeJob' + end + + def job_id + 'job-1' + end + + def queue_name + 'default' + end + + def priority + nil + end + + def executions + 1 + end + + def arguments + [] + end + + def perform_now + raise StandardError, 'job failed' + end + end + end + + it 'tags job captures as unhandled active_job exceptions' do + PostHog::Rails.config.auto_instrument_active_job = true + + expect { job_class.new.perform_now }.to raise_error(StandardError, 'job failed') + + expect(PostHog).to have_received(:capture_exception).with( + an_instance_of(StandardError), + anything, + an_instance_of(Hash), + mechanism: { 'type' => 'active_job', 'handled' => false } + ) + end + end +end From 3dd76db6f0a554da745d4de0ab40ecdac0d53298 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 2 Jul 2026 21:08:57 +0200 Subject: [PATCH 2/5] fix: address exception capture review --- lib/posthog/exception_capture.rb | 6 +-- .../posthog/rails/exception_mechanism_spec.rb | 49 +++++++------------ 2 files changed, 22 insertions(+), 33 deletions(-) diff --git a/lib/posthog/exception_capture.rb b/lib/posthog/exception_capture.rb index 5430444c..5a62f44d 100644 --- a/lib/posthog/exception_capture.rb +++ b/lib/posthog/exception_capture.rb @@ -39,15 +39,15 @@ def self.build_exception_list(value, mechanism: nil) root_mechanism = DEFAULT_MECHANISM.merge(mechanism || {}) exceptions = [] - seen = [] + seen = {}.compare_by_identity current = value - while current && exceptions.length < MAX_CHAINED_EXCEPTIONS && seen.none? { |e| e.equal?(current) } + while current && exceptions.length < MAX_CHAINED_EXCEPTIONS && !seen.key?(current) parsed = build_parsed_exception(current, mechanism: chain_mechanism(root_mechanism, exceptions.length)) break if parsed.nil? exceptions << parsed - seen << current + seen[current] = true current = current.respond_to?(:cause) ? current.cause : nil end diff --git a/spec/posthog/rails/exception_mechanism_spec.rb b/spec/posthog/rails/exception_mechanism_spec.rb index 19f42ca6..c38a9153 100644 --- a/spec/posthog/rails/exception_mechanism_spec.rb +++ b/spec/posthog/rails/exception_mechanism_spec.rb @@ -42,36 +42,25 @@ end describe PostHog::Rails::ErrorSubscriber do - it 'forwards the handled flag reported by Rails' do - described_class.new.report( - StandardError.new('boom'), - handled: true, - severity: :warning, - context: {} - ) - - expect(PostHog).to have_received(:capture_exception).with( - an_instance_of(StandardError), - anything, - an_instance_of(Hash), - mechanism: { 'type' => 'rails_error_reporter', 'handled' => true } - ) - end - - it 'tags unhandled reports as unhandled' do - described_class.new.report( - StandardError.new('boom'), - handled: false, - severity: :error, - context: {} - ) - - expect(PostHog).to have_received(:capture_exception).with( - an_instance_of(StandardError), - anything, - an_instance_of(Hash), - mechanism: { 'type' => 'rails_error_reporter', 'handled' => false } - ) + [ + { handled: true, severity: :warning, description: 'forwards the handled flag reported by Rails' }, + { handled: false, severity: :error, description: 'tags unhandled reports as unhandled' } + ].each do |scenario| + it scenario[:description] do + described_class.new.report( + StandardError.new('boom'), + handled: scenario[:handled], + severity: scenario[:severity], + context: {} + ) + + expect(PostHog).to have_received(:capture_exception).with( + an_instance_of(StandardError), + anything, + an_instance_of(Hash), + mechanism: { 'type' => 'rails_error_reporter', 'handled' => scenario[:handled] } + ) + end end end From 3a109f658c089bb06fa62000ca0df49da430f974 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 2 Jul 2026 21:00:06 +0200 Subject: [PATCH 3/5] feat: use relative frame filenames --- .changeset/eager-owls-chain.md | 1 + lib/posthog/exception_capture.rb | 72 +++++++++++++++++++++++--- spec/posthog/exception_capture_spec.rb | 46 +++++++++++++--- 3 files changed, 105 insertions(+), 14 deletions(-) diff --git a/.changeset/eager-owls-chain.md b/.changeset/eager-owls-chain.md index 2fb551c6..4681c27e 100644 --- a/.changeset/eager-owls-chain.md +++ b/.changeset/eager-owls-chain.md @@ -7,3 +7,4 @@ Improve error tracking capture signals: - `capture_exception` now walks the full `exception.cause` chain (outermost-first, cycle-safe, capped at 50) instead of reporting only the outermost exception; chained causes are tagged with a `chained` mechanism and parent linkage. - Exception mechanisms now reflect the capture source: manual captures stay `generic`/`handled: true`, while the Rails middleware, `Rails.error` subscriber, and ActiveJob integrations tag captures as `rails`/`rails_error_reporter`/`active_job` with the correct `handled` flag. `capture_exception` accepts a `mechanism:` keyword. +- Stack frame `filename` values are now project-relative paths (relative to the Rails root or `Dir.pwd`, falling back to the basename outside the project) so fingerprints stay stable across per-host deploy directories; `abs_path` keeps the raw absolute path. `in_app` is now based on gem/stdlib install locations (`Gem.path`, loaded gem specs, `rubylibprefix`) instead of path substrings. diff --git a/lib/posthog/exception_capture.rb b/lib/posthog/exception_capture.rb index 5a62f44d..4042fad4 100644 --- a/lib/posthog/exception_capture.rb +++ b/lib/posthog/exception_capture.rb @@ -97,8 +97,9 @@ def self.build_single_exception_from_data(title, message, backtrace, mechanism: def self.build_stacktrace(backtrace) return nil unless backtrace && !backtrace.empty? + root = project_root frames = backtrace.first(50).map do |line| - parse_backtrace_line(line) + parse_backtrace_line(line, project_root: root) end.compact.reverse { @@ -108,8 +109,9 @@ def self.build_stacktrace(backtrace) end # @param line [String] + # @param project_root [String, nil] Project root used to derive project-relative filenames. # @return [Hash, nil] - def self.parse_backtrace_line(line) + def self.parse_backtrace_line(line, project_root: self.project_root) match = line.match(RUBY_INPUT_FORMAT) return nil unless match @@ -118,7 +120,7 @@ def self.parse_backtrace_line(line) method_name = match[5] frame = { - 'filename' => File.basename(file), + 'filename' => frame_filename(file, project_root), 'abs_path' => file, 'lineno' => lineno, 'function' => method_name, @@ -131,13 +133,69 @@ def self.parse_backtrace_line(line) frame end + # Root directory the application runs from, used to derive stable + # project-relative filenames from per-host deploy paths + # (e.g. `/app/releases/20240101/...`). + # + # @return [String] + def self.project_root + if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root + ::Rails.root.to_s + else + Dir.pwd + end + rescue StandardError + Dir.pwd + end + + # Stable filename used for fingerprinting: the project-relative path when + # the file lives inside the project root, its basename otherwise. The raw + # absolute path is kept separately in `abs_path`. + # + # @param path [String] + # @param project_root [String, nil] + # @return [String] + def self.frame_filename(path, project_root) + if project_root && !project_root.empty? && path_within?(path, project_root) + relative = path[project_root.length..] + relative = relative[1..] while relative.start_with?(File::SEPARATOR) + return relative unless relative.empty? + end + + File.basename(path) + end + + # Whether the path belongs to an installed gem or the Ruby standard library, + # based on gem install locations rather than path substrings. + # # @param path [String] # @return [Boolean] def self.gem_path?(path) - path.include?('/gems/') || - path.include?('/ruby/') || - path.include?('/.rbenv/') || - path.include?('/.rvm/') + dependency_roots.any? { |root| path_within?(path, root) } + end + + # @return [Array] Directories containing installed gems and the Ruby stdlib. + def self.dependency_roots + roots = [] + if defined?(Gem) + roots.concat(Gem.path) if Gem.respond_to?(:path) + roots << Gem.default_dir if Gem.respond_to?(:default_dir) + roots.concat(Gem.loaded_specs.each_value.map(&:full_gem_path)) if Gem.respond_to?(:loaded_specs) + end + roots << RbConfig::CONFIG['rubylibprefix'] if defined?(RbConfig) + # The project itself can be a loaded spec (e.g. a gem developed in place, + # or a Rails engine); its frames are still application code. + roots.compact.reject(&:empty?).uniq - [project_root] + rescue StandardError + [] + end + + # @param path [String] + # @param root [String] + # @return [Boolean] + def self.path_within?(path, root) + root = root.chomp(File::SEPARATOR) + path == root || path.start_with?("#{root}#{File::SEPARATOR}") end # @param frame [Hash] diff --git a/spec/posthog/exception_capture_spec.rb b/spec/posthog/exception_capture_spec.rb index 5f51da44..beeda773 100644 --- a/spec/posthog/exception_capture_spec.rb +++ b/spec/posthog/exception_capture_spec.rb @@ -20,18 +20,26 @@ module PostHog 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) + gem_file = File.join(Gem.path.first, 'gems', 'some_gem-1.0.0', 'lib', 'some_gem.rb') + frame = described_class.parse_backtrace_line("#{gem_file}:10:in `gem_method'") + + expect(frame['in_app']).to be false + end + + it 'identifies standard library files correctly' do + stdlib_file = File.join(RbConfig::CONFIG['rubylibdir'], 'json.rb') + frame = described_class.parse_backtrace_line("#{stdlib_file}:10:in `parse'") expect(frame['in_app']).to be false 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 - gem_line = "#{__FILE__}:10:in `gem_method'" - .gsub(%r{/spec/}, '/gems/ruby/spec/') - frame = described_class.parse_backtrace_line(gem_line) + # Use a real file from a loaded gem so File.exist? is true + # but in_app is false, so context lines should not be added + gem_file = File.join(Gem.loaded_specs['rspec-core'].full_gem_path, 'lib', 'rspec', 'core.rb') + expect(File.exist?(gem_file)).to be true + + frame = described_class.parse_backtrace_line("#{gem_file}:10:in `gem_method'") expect(frame['in_app']).to be false expect(frame['context_line']).to be_nil @@ -39,6 +47,30 @@ module PostHog expect(frame['post_context']).to be_nil end + it 'uses project-relative filenames for paths inside the project root' do + line = '/app/releases/20240101/app/models/user.rb:42:in `validate_email\'' + frame = described_class.parse_backtrace_line(line, project_root: '/app/releases/20240101') + + expect(frame['filename']).to eq('app/models/user.rb') + expect(frame['abs_path']).to eq('/app/releases/20240101/app/models/user.rb') + end + + it 'falls back to the basename for paths outside the project root' do + line = '/somewhere/else/app/models/user.rb:42:in `validate_email\'' + frame = described_class.parse_backtrace_line(line, project_root: '/app/releases/20240101') + + expect(frame['filename']).to eq('user.rb') + expect(frame['abs_path']).to eq('/somewhere/else/app/models/user.rb') + end + + it 'derives filenames for real project files relative to the current working directory' do + frame = described_class.parse_backtrace_line("#{File.expand_path(__FILE__)}:10:in `app_method'") + + expect(frame['filename']).to eq('spec/posthog/exception_capture_spec.rb') + expect(frame['abs_path']).to eq(File.expand_path(__FILE__)) + expect(frame['in_app']).to be true + end + it 'adds context lines for in_app frames' do # Use a real in_app path so File.exist? is true and in_app is true app_line = "#{__FILE__}:10:in `app_method'" From 6ee6b32a85e12847f8acbb683d38aa0a91116d45 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 2 Jul 2026 21:22:16 +0200 Subject: [PATCH 4/5] fix: cache dependency roots per stacktrace --- lib/posthog/exception_capture.rb | 14 +++++++++----- spec/posthog/exception_capture_spec.rb | 13 +++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/posthog/exception_capture.rb b/lib/posthog/exception_capture.rb index 4042fad4..08d69d4f 100644 --- a/lib/posthog/exception_capture.rb +++ b/lib/posthog/exception_capture.rb @@ -98,8 +98,9 @@ def self.build_stacktrace(backtrace) return nil unless backtrace && !backtrace.empty? root = project_root + roots = dependency_roots(root) frames = backtrace.first(50).map do |line| - parse_backtrace_line(line, project_root: root) + parse_backtrace_line(line, project_root: root, dependency_roots: roots) end.compact.reverse { @@ -110,8 +111,9 @@ def self.build_stacktrace(backtrace) # @param line [String] # @param project_root [String, nil] Project root used to derive project-relative filenames. + # @param dependency_roots [Array, nil] Cached gem and stdlib roots. # @return [Hash, nil] - def self.parse_backtrace_line(line, project_root: self.project_root) + def self.parse_backtrace_line(line, project_root: self.project_root, dependency_roots: nil) match = line.match(RUBY_INPUT_FORMAT) return nil unless match @@ -124,7 +126,7 @@ def self.parse_backtrace_line(line, project_root: self.project_root) 'abs_path' => file, 'lineno' => lineno, 'function' => method_name, - 'in_app' => !gem_path?(file), + 'in_app' => !gem_path?(file, dependency_roots || self.dependency_roots(project_root)), 'platform' => 'ruby' } @@ -169,13 +171,15 @@ def self.frame_filename(path, project_root) # based on gem install locations rather than path substrings. # # @param path [String] + # @param dependency_roots [Array] # @return [Boolean] - def self.gem_path?(path) + def self.gem_path?(path, dependency_roots = self.dependency_roots) dependency_roots.any? { |root| path_within?(path, root) } end + # @param project_root [String] # @return [Array] Directories containing installed gems and the Ruby stdlib. - def self.dependency_roots + def self.dependency_roots(project_root = self.project_root) roots = [] if defined?(Gem) roots.concat(Gem.path) if Gem.respond_to?(:path) diff --git a/spec/posthog/exception_capture_spec.rb b/spec/posthog/exception_capture_spec.rb index beeda773..f4234397 100644 --- a/spec/posthog/exception_capture_spec.rb +++ b/spec/posthog/exception_capture_spec.rb @@ -143,6 +143,19 @@ def test_method_that_throws expect(stacktrace['frames'][0]['filename']).to eq('action_controller.rb') expect(stacktrace['frames'][1]['filename']).to eq('user.rb') end + + it 'computes dependency roots once for all frames' do + backtrace = [ + '/app/app/models/user.rb:42:in `validate_email\'', + '/app/app/controllers/users_controller.rb:10:in `show\'' + ] + + expect(described_class).to receive(:dependency_roots).once.and_return([]) + + stacktrace = described_class.build_stacktrace(backtrace) + + expect(stacktrace['frames'].length).to eq(2) + end end describe '#build_parsed_exception' do From d9a6e26122b4461567660e2067e166c24838a1fb Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Fri, 3 Jul 2026 14:04:58 +0200 Subject: [PATCH 5/5] Update frame filename changeset --- .changeset/eager-owls-chain.md | 1 - .changeset/tidy-frames-drum.md | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-frames-drum.md diff --git a/.changeset/eager-owls-chain.md b/.changeset/eager-owls-chain.md index 4681c27e..2fb551c6 100644 --- a/.changeset/eager-owls-chain.md +++ b/.changeset/eager-owls-chain.md @@ -7,4 +7,3 @@ Improve error tracking capture signals: - `capture_exception` now walks the full `exception.cause` chain (outermost-first, cycle-safe, capped at 50) instead of reporting only the outermost exception; chained causes are tagged with a `chained` mechanism and parent linkage. - Exception mechanisms now reflect the capture source: manual captures stay `generic`/`handled: true`, while the Rails middleware, `Rails.error` subscriber, and ActiveJob integrations tag captures as `rails`/`rails_error_reporter`/`active_job` with the correct `handled` flag. `capture_exception` accepts a `mechanism:` keyword. -- Stack frame `filename` values are now project-relative paths (relative to the Rails root or `Dir.pwd`, falling back to the basename outside the project) so fingerprints stay stable across per-host deploy directories; `abs_path` keeps the raw absolute path. `in_app` is now based on gem/stdlib install locations (`Gem.path`, loaded gem specs, `rubylibprefix`) instead of path substrings. diff --git a/.changeset/tidy-frames-drum.md b/.changeset/tidy-frames-drum.md new file mode 100644 index 00000000..39b07a46 --- /dev/null +++ b/.changeset/tidy-frames-drum.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": minor +--- + +Use stable project-relative stack frame `filename` values (relative to `Rails.root` or `Dir.pwd`, falling back to the basename outside the project) while preserving the raw path in `abs_path`; dependency and stdlib frames are detected from RubyGems/stdlib install roots, computed once per stacktrace, so `in_app` no longer depends on deploy-path substrings.