Skip to content
Draft
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/canonical-exception-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'posthog-ruby': minor
'posthog-rails': minor
---

Standardize exception capture metadata, including severity, capture source, mechanism semantics, deterministic cause linkage, and reserved property ownership.
22 changes: 19 additions & 3 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -384,15 +384,31 @@ def capture(attrs)
# `{ '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, mechanism: nil)
def capture_exception(
exception,
distinct_id = nil,
additional_properties = {},
flags: nil,
mechanism: nil,
level: nil,
source: nil
)
return false if @disabled

exception_list = ExceptionCapture.build_exception_list(exception, mechanism: mechanism)

return if exception_list.nil?

properties = { '$exception_list' => exception_list }
properties.merge!(additional_properties) if additional_properties && !additional_properties.empty?
properties = if additional_properties
additional_properties.reject do |key, _value|
ExceptionCapture::RESERVED_EXCEPTION_PROPERTIES.include?(key.to_s)
end
else
{}
end
properties['$exception_list'] = exception_list
properties['$exception_level'] = ExceptionCapture.normalize_level(level) || 'error'
properties['$exception_source'] = source if source.is_a?(String) && !source.empty?

event_data = {
distinct_id: distinct_id,
Expand Down
61 changes: 53 additions & 8 deletions lib/posthog/exception_capture.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ module ExceptionCapture
MAX_CHAINED_EXCEPTIONS = 50

DEFAULT_MECHANISM = { 'type' => 'generic', 'handled' => true }.freeze
RESERVED_EXCEPTION_PROPERTIES = %w[
$exception_list $exception_level $exception_source $debug_images
$exception_handled $exception_types $exception_values $exception_sources
$exception_functions $exception_fingerprint_version $exception_fingerprint_record
$exception_issue_id $exception_release $cymbal_errors
].freeze

# Builds the `$exception_list` payload for an exception, walking its
# `cause` chain outermost-first (wrapper first, root cause last).
Expand All @@ -36,7 +42,7 @@ module ExceptionCapture
# 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 || {})
root_mechanism = DEFAULT_MECHANISM.merge(valid_mechanism(mechanism))

exceptions = []
seen = {}.compare_by_identity
Expand All @@ -58,14 +64,15 @@ def self.build_exception_list(value, mechanism: nil)
# @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?
return root_mechanism.merge('exception_id' => exception_id) if exception_id.zero?

mechanism.merge(
{
'type' => 'chained',
'source' => 'cause',
'synthetic' => false,
'exception_id' => exception_id,
'parent_id' => exception_id - 1
)
}
end

# @param value [Exception, String, Object] Exception input to parse.
Expand All @@ -75,23 +82,61 @@ 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, mechanism: mechanism)
build_single_exception_from_data(
title,
message,
backtrace,
mechanism: mechanism,
synthetic: !value.is_a?(Exception)
)
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, mechanism: nil)
def self.build_single_exception_from_data(title, message, backtrace, mechanism: nil, synthetic: false)
valid = valid_mechanism(mechanism)
resolved_mechanism = DEFAULT_MECHANISM.merge(valid).merge('synthetic' => synthetic)
resolved_mechanism.delete('handled') if resolved_mechanism['type'] == 'chained' && !valid.key?('handled')
{
'type' => title,
'value' => message || '',
'mechanism' => DEFAULT_MECHANISM.merge(mechanism || {}),
'mechanism' => resolved_mechanism,
'stacktrace' => build_stacktrace(backtrace)
}
end

def self.valid_mechanism(mechanism)
return {} unless mechanism.is_a?(Hash)

result = mechanism.reject do |key, _value|
%w[type handled source synthetic exception_id parent_id].include?(key.to_s)
end.transform_keys(&:to_s)
type = mechanism['type'] || mechanism[:type]
handled = mechanism['handled'].nil? ? mechanism[:handled] : mechanism['handled']
source = mechanism['source'] || mechanism[:source]
synthetic = mechanism['synthetic'].nil? ? mechanism[:synthetic] : mechanism['synthetic']
exception_id = mechanism['exception_id'] || mechanism[:exception_id]
parent_id = mechanism['parent_id'] || mechanism[:parent_id]
result['type'] = type if type.is_a?(String) && !type.empty?
result['handled'] = handled if [true, false].include?(handled)
result['source'] = source if source.is_a?(String) && !source.empty?
result['synthetic'] = synthetic if [true, false].include?(synthetic)
result['exception_id'] = exception_id if exception_id.is_a?(Integer) && exception_id >= 0
result['parent_id'] = parent_id if parent_id.is_a?(Integer) && parent_id >= 0
result
end

def self.normalize_level(level)
{
'fatal' => 'fatal', 'critical' => 'fatal', 'alert' => 'fatal', 'emergency' => 'fatal',
'error' => 'error', 'warning' => 'warning', 'warn' => 'warning', 'log' => 'log',
'notice' => 'info', 'info' => 'info', 'trace' => 'debug', 'debug' => 'debug'
}[level.to_s.downcase]
end

# @param backtrace [Array<String>, nil]
# @return [Hash, nil]
def self.build_stacktrace(backtrace)
Expand Down
5 changes: 3 additions & 2 deletions posthog-rails/lib/posthog/rails/active_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ def capture_job_exception(exception)
distinct_id = extract_distinct_id_from_job

properties = {
'$exception_source' => 'active_job',
'$job_class' => self.class.name,
'$job_id' => job_id,
'$queue_name' => queue_name,
Expand All @@ -68,7 +67,9 @@ def capture_job_exception(exception)
exception,
distinct_id,
properties,
mechanism: { 'type' => 'active_job', 'handled' => false }
mechanism: { 'type' => 'task', 'handled' => false },
level: 'error',
source: 'rails.active_job'
)
PostHog::Rails.mark_active_job_exception_captured(exception)
rescue StandardError => e
Expand Down
14 changes: 7 additions & 7 deletions posthog-rails/lib/posthog/rails/capture_exceptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ def call(env)
# Check if there was an exception that Rails handled
exception = collect_exception(env)

capture_exception(exception, env) if exception && should_capture?(exception)
capture_exception(exception, env, handled: true) if exception && should_capture?(exception)

response
rescue StandardError => e
# Capture unhandled exceptions
capture_exception(e, env) if should_capture?(e)
capture_exception(e, env, handled: false) if should_capture?(e)
raise
ensure
PostHog::Rails.exit_web_request
Expand All @@ -55,7 +55,7 @@ def should_capture?(exception)
true
end

def capture_exception(exception, env)
def capture_exception(exception, env, handled:)
request = ActionDispatch::Request.new(env)
distinct_id = extract_distinct_id(env)
additional_properties = build_properties(request, env)
Expand All @@ -64,7 +64,9 @@ def capture_exception(exception, env)
exception,
distinct_id,
additional_properties,
mechanism: { 'type' => 'rails', 'handled' => false }
mechanism: { 'type' => 'middleware', 'handled' => handled },
level: 'error',
source: 'rails.middleware'
)
PostHog::Rails.mark_web_exception_captured(exception) if captured
rescue StandardError => e
Expand Down Expand Up @@ -130,9 +132,7 @@ def extract_user_id(user)
end

def build_properties(request, env)
properties = {
'$exception_source' => 'rails'
}
properties = {}

# Add controller and action if available
if env['action_controller.instance']
Expand Down
9 changes: 5 additions & 4 deletions posthog-rails/lib/posthog/rails/error_subscriber.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,8 @@ def report(error, handled:, severity:, context:, source: nil)
distinct_id = context[:user_id] || context[:distinct_id]

properties = {
'$exception_source' => source || 'rails_error_reporter',
'$exception_handled' => handled,
'$exception_severity' => severity.to_s
'$rails_error_source' => source,
'$rails_error_severity' => severity.to_s
}

# Add context information (safely serialized to avoid circular references)
Expand All @@ -51,7 +50,9 @@ def report(error, handled:, severity:, context:, source: nil)
error,
distinct_id,
properties,
mechanism: { 'type' => 'rails_error_reporter', 'handled' => handled }
mechanism: { 'type' => 'error_reporter', 'handled' => handled },
level: severity,
source: 'rails.error_reporter'
)
rescue StandardError => e
PostHog::Logging.logger.error("Failed to report error via subscriber: #{e.message}")
Expand Down
8 changes: 5 additions & 3 deletions spec/posthog/exception_capture_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ def raise_chained
expect(exception_list.first['mechanism']).to eq(
'type' => 'generic',
'handled' => true,
'synthetic' => false,
'exception_id' => 0
)
end
Expand All @@ -279,18 +280,19 @@ def raise_chained
expect(exception_list[0]['mechanism']).to eq(
'type' => 'generic',
'handled' => true,
'synthetic' => false,
'exception_id' => 0
)
expect(exception_list[1]['mechanism']).to eq(
'type' => 'chained',
'handled' => true,
'synthetic' => false,
'source' => 'cause',
'exception_id' => 1,
'parent_id' => 0
)
expect(exception_list[2]['mechanism']).to eq(
'type' => 'chained',
'handled' => true,
'synthetic' => false,
'source' => 'cause',
'exception_id' => 2,
'parent_id' => 1
Expand All @@ -305,7 +307,7 @@ def raise_chained
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
expect(exception_list[1]['mechanism']).not_to have_key('handled')
end

it 'guards against cycles in the cause chain' do
Expand Down
36 changes: 25 additions & 11 deletions spec/posthog/rails/exception_mechanism_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
an_instance_of(StandardError),
anything,
an_instance_of(Hash),
mechanism: { 'type' => 'rails', 'handled' => false }
mechanism: { 'type' => 'middleware', 'handled' => false },
level: 'error',
source: 'rails.middleware'
)
end

Expand Down Expand Up @@ -70,8 +72,10 @@
expect(PostHog).to have_received(:capture_exception).with(
an_instance_of(StandardError),
anything,
hash_including('$exception_source' => 'rails'),
mechanism: { 'type' => 'rails', 'handled' => false }
an_instance_of(Hash),
mechanism: { 'type' => 'middleware', 'handled' => false },
level: 'error',
source: 'rails.middleware'
)
end

Expand Down Expand Up @@ -100,8 +104,10 @@
expect(PostHog).to have_received(:capture_exception).with(
error,
anything,
hash_including('$exception_source' => 'application.action_dispatch'),
mechanism: { 'type' => 'rails_error_reporter', 'handled' => false }
hash_including('$rails_error_source' => 'application.action_dispatch'),
mechanism: { 'type' => 'error_reporter', 'handled' => false },
level: :error,
source: 'rails.error_reporter'
)
end
end
Expand All @@ -123,7 +129,9 @@
an_instance_of(StandardError),
anything,
an_instance_of(Hash),
mechanism: { 'type' => 'rails_error_reporter', 'handled' => scenario[:handled] }
mechanism: { 'type' => 'error_reporter', 'handled' => scenario[:handled] },
level: scenario[:severity],
source: 'rails.error_reporter'
)
end
end
Expand Down Expand Up @@ -199,8 +207,10 @@
expect(PostHog).to have_received(:capture_exception).with(
an_instance_of(StandardError),
anything,
hash_including('$exception_source' => 'application.active_support'),
mechanism: { 'type' => 'rails_error_reporter', 'handled' => false }
hash_including('$rails_error_source' => 'application.active_support'),
mechanism: { 'type' => 'error_reporter', 'handled' => false },
level: :error,
source: 'rails.error_reporter'
)
end
end
Expand Down Expand Up @@ -250,7 +260,9 @@ def perform_now
an_instance_of(StandardError),
anything,
an_instance_of(Hash),
mechanism: { 'type' => 'active_job', 'handled' => false }
mechanism: { 'type' => 'task', 'handled' => false },
level: 'error',
source: 'rails.active_job'
)
end

Expand All @@ -276,8 +288,10 @@ def perform_now
expect(PostHog).to have_received(:capture_exception).with(
an_instance_of(StandardError),
anything,
hash_including('$exception_source' => 'active_job'),
mechanism: { 'type' => 'active_job', 'handled' => false }
an_instance_of(Hash),
mechanism: { 'type' => 'task', 'handled' => false },
level: 'error',
source: 'rails.active_job'
)
end
end
Expand Down
Loading