From 209a4ec113b96c2e4e1e7714de3c2af8cd53f030 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 2 Jul 2026 20:59:25 +0200 Subject: [PATCH 1/2] 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/2] 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