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
5 changes: 5 additions & 0 deletions .changeset/rails-controller-exception-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-rails": minor
---

Add a private controller capture_exception helper with filtered request metadata, configured user identity, explicit argument precedence, and the existing exception capture pipeline. Manual reports work independently of automatic exception capture without initializing an unavailable client.
14 changes: 14 additions & 0 deletions posthog-rails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ Existing canonical attributes take precedence. Missing IDs stay absent. Spans ke

See the [Rails trace context guide](https://posthog.com/docs/libraries/ruby-on-rails#trace-context) for setup, middleware order, and trust limits. The bridge does not replace the application's provider, exporter, or sampler. Optional Rails log forwarding uses the same request context.

## Controller exception reporting

Controllers and API controllers gain a private `capture_exception(exception, distinct_id = nil, additional_properties = {}, flags: nil, mechanism: nil)` helper. Existing controller helpers with that name are not replaced. It returns the core capture result (`true`, `false`, or `nil`) and is not a controller action.

The existing `PostHog.capture_exception` API remains available. This helper keeps configured-user resolution, controller/action and filtered parameters local to an explicit exception-reporting call rather than adding them to every event through `RequestContext`.

Manual reports do not require `auto_capture_exceptions` and do not apply automatic exception exclusions. They retain the core manual handled-exception defaults and duplicate prevention. Missing or disabled clients no-op without initializing a client.

These remain plain manual captures: the default mechanism is `generic` with `handled: true`, and the helper does not add `$exception_source`. Adding Rails context does not introduce a new automatic capture boundary.

Explicit identity wins over the configured current user, then request identity. Explicit properties win over inferred request properties. The helper respects `capture_user_context`, current-user resolution settings, and `use_tracing_headers`. Request parameters use Rails `filter_parameters`; automatically derived URLs omit queries and fragments. Explicit properties and exception messages are not parameter-filtered. Unavailable or malformed enrichment is omitted without dropping the original report or replacing application exceptions. Request context is not retained after the request.

Manual reports use already-parsed Rails parameter/body caches, with query/path parameters as fallback. The helper does not proactively parse uncached bodies; richer body collection is outside this helper. Rails may independently parse or log bodies before an action runs; the helper does not suppress framework-owned diagnostics. A response status is included only when already known, including after a controller has performed its response; the default pre-response status is not inferred.

## Streaming exceptions

With `auto_capture_exceptions` enabled (default `false`), errors reported by ActionController::Live and ActionView streaming templates are captured through the existing exception pipeline, respecting excluded exceptions and duplicate prevention. Rails logging and streaming completion behavior are unchanged.
Expand Down
1 change: 1 addition & 0 deletions posthog-rails/lib/posthog/rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require 'posthog/rails/request_metadata'
require 'posthog/rails/request_context'
require 'posthog/rails/capture_exceptions'
require 'posthog/rails/controller_helpers'
require 'posthog/rails/streaming_exceptions'
require 'posthog/rails/rescued_exception_interceptor'
require 'posthog/rails/active_job'
Expand Down
34 changes: 23 additions & 11 deletions posthog-rails/lib/posthog/rails/action_cable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,29 @@

module PostHog
module Rails
# @api private
class ActionCableExceptionContext < ExceptionContext
def initialize(request, connection)
super(request)
@connection = connection
end

private

def extract_current_user(controller)
return super if controller

method_name = PostHog::Rails.config&.current_user_method || :current_user
@connection.send(method_name) if @connection.respond_to?(method_name, true)
rescue StandardError
nil
end
end

# @api private
class ActionCableCapture < CaptureExceptions
include ParameterFilter

def initialize(connection, operation, channel: nil, data: nil)
super(nil)
@connection = connection
Expand Down Expand Up @@ -76,16 +97,7 @@ def capture_exception(exception, env)
end

def extract_distinct_id(env)
super
rescue StandardError
nil
end

def extract_current_user(controller)
return super if controller

method_name = PostHog::Rails.config&.current_user_method || :current_user
@connection.send(method_name) if @connection.respond_to?(method_name, true)
ActionCableExceptionContext.new(ActionDispatch::Request.new(env), @connection).distinct_id
rescue StandardError
nil
end
Expand Down Expand Up @@ -213,7 +225,7 @@ def perform_action(data)
end
end

private_constant :ActionCableCapture, :ActionCableContext,
private_constant :ActionCableExceptionContext, :ActionCableCapture, :ActionCableContext,
:ActionCableConnectionExtensions, :ActionCableChannelExtensions,
:ActionCableDispatch, :ActionCableSubscriptionsExtensions
end
Expand Down
98 changes: 4 additions & 94 deletions posthog-rails/lib/posthog/rails/capture_exceptions.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
# frozen_string_literal: true

require 'posthog/rails/parameter_filter'
require 'posthog/rails/exception_context'

module PostHog
module Rails
# Middleware that captures exceptions and sends them to PostHog.
#
# @api private
class CaptureExceptions
include ParameterFilter

# @param app [#call] Rack application.
def initialize(app)
@app = app
Expand Down Expand Up @@ -51,8 +49,9 @@ def should_capture?(exception)

def capture_exception(exception, env)
request = ActionDispatch::Request.new(env)
distinct_id = extract_distinct_id(env)
additional_properties = build_properties(request, env)
context = ExceptionContext.new(request)
distinct_id = context.distinct_id
additional_properties = { '$exception_source' => 'rails' }.merge(context.properties)

PostHog.capture_exception(
exception,
Expand All @@ -65,99 +64,10 @@ def capture_exception(exception, env)
PostHog::Logging.logger.error("Backtrace: #{e.backtrace&.first(5)&.join("\n")}")
end

def extract_distinct_id(env)
# Prefer authenticated Rails user context. Request/tracing context is
# applied later by the core capture path if this returns nil.
return nil unless PostHog::Rails.config&.capture_user_context

user = extract_current_user(env['action_controller.instance'])
user_id = extract_user_id(user) if user
return user_id if present?(user_id)

nil
end

def extract_current_user(controller)
resolver = PostHog::Rails.config&.current_user_resolver
return resolve_current_user(resolver, controller) if resolver

method_name = PostHog::Rails.config&.current_user_method || :current_user
return unless controller.respond_to?(method_name, true)

controller.send(method_name)
end

def resolve_current_user(resolver, controller)
call_current_user_resolver(resolver, controller)
rescue StandardError => e
PostHog::Logging.logger.warn("current_user_resolver failed: #{e.message}")
nil
end

def call_current_user_resolver(resolver, controller)
if resolver.arity.zero?
controller ? controller.instance_exec(&resolver) : resolver.call
elsif controller
resolver.call(controller)
end
end

def extract_user_id(user)
# Use configured method if specified
method_name = PostHog::Rails.config&.user_id_method
return user.send(method_name) if method_name && user.respond_to?(method_name)

# Try explicit PostHog method (allows users to customize without config)
return user.posthog_distinct_id if user.respond_to?(:posthog_distinct_id)
return user.distinct_id if user.respond_to?(:distinct_id)

# Try common ID methods
return user.id if user.respond_to?(:id)
return user['id'] if user.respond_to?(:[]) && user['id']
return user.pk if user.respond_to?(:pk)
return user['pk'] if user.respond_to?(:[]) && user['pk']
return user.uuid if user.respond_to?(:uuid)
return user['uuid'] if user.respond_to?(:[]) && user['uuid']

user.to_s
end

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

# Add controller and action if available
if env['action_controller.instance']
controller = env['action_controller.instance']
properties['$controller'] = safe_serialize(controller.controller_name)
properties['$action'] = safe_serialize(controller.action_name)
end

# Add request parameters (be careful with sensitive data)
if request.params.present?
filtered_params = filter_sensitive_params(request.params)
# Safe serialize to handle any complex objects in params
properties['$request_params'] = safe_serialize(filtered_params) unless filtered_params.empty?
end

response_status_code = env['posthog.response_status_code']
properties['$response_status_code'] = response_status_code if response_status_code

# Add referrer
properties['$referrer'] = safe_serialize(request.referrer) if request.referrer

properties
end

def response_status(response)
status = response.respond_to?(:[]) ? response[0] : nil
status if status.is_a?(Integer)
end

def present?(value)
!(value.nil? || (value.respond_to?(:empty?) && value.empty?))
end
end
end
end
33 changes: 33 additions & 0 deletions posthog-rails/lib/posthog/rails/controller_helpers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

require 'posthog/rails/exception_context'

module PostHog
module Rails
module ControllerHelpers
private

# Report a handled exception with filtered Rails request context.
# Manual reporting is independent of automatic capture and its exclusions.
#
# @param exception [Exception, String, Object] Input accepted by {PostHog::Client#capture_exception}.
# @param distinct_id [String, nil] Explicit identity, before configured user and request identity.
# @param additional_properties [Hash, nil] Explicit properties override inferred request properties.
# @param flags [PostHog::FeatureFlagEvaluations, nil] Flag evaluation snapshot.
# @param mechanism [Hash, nil] Explicit exception mechanism; defaults to core manual capture semantics.
# @return [Boolean, nil] The result of {PostHog.capture_exception}, or false when unavailable or capture fails.
def capture_exception(exception, distinct_id = nil, additional_properties = {}, flags: nil, mechanism: nil)
Comment thread
marandaneto marked this conversation as resolved.
Resolved
return false unless PostHog.initialized? && PostHog.client.enabled?

context = ExceptionContext.new(request, self, manual: true)
distinct_id = context.distinct_id if distinct_id.nil?
properties = context.properties.merge(additional_properties || {})

PostHog.capture_exception(exception, distinct_id, properties, flags: flags, mechanism: mechanism)
Comment thread
marandaneto marked this conversation as resolved.
Resolved
rescue StandardError
PostHog::Logging.logger.error('Failed to capture controller exception')
false
end
end
end
end
Loading
Loading