Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/eager-owls-chain.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 7 additions & 4 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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/<key>` 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 = {
Expand Down
59 changes: 52 additions & 7 deletions lib/posthog/exception_capture.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hash>, 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 = {}.compare_by_identity
current = value

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] = true
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<String>, 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
Expand Down
7 changes: 6 additions & 1 deletion posthog-rails/lib/posthog/rails/active_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
7 changes: 6 additions & 1 deletion posthog-rails/lib/posthog/rails/capture_exceptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")}")
Expand Down
7 changes: 6 additions & 1 deletion posthog-rails/lib/posthog/rails/error_subscriber.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")}")
Expand Down
2 changes: 1 addition & 1 deletion public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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?()
Expand Down
37 changes: 37 additions & 0 deletions spec/posthog/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
139 changes: 139 additions & 0 deletions spec/posthog/exception_capture_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading