diff --git a/.changeset/rails-controller-exception-helper.md b/.changeset/rails-controller-exception-helper.md new file mode 100644 index 00000000..2c103f00 --- /dev/null +++ b/.changeset/rails-controller-exception-helper.md @@ -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. diff --git a/posthog-rails/README.md b/posthog-rails/README.md index 414099d6..1aa68855 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -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. diff --git a/posthog-rails/lib/posthog/rails.rb b/posthog-rails/lib/posthog/rails.rb index 9ac57396..d77080af 100644 --- a/posthog-rails/lib/posthog/rails.rb +++ b/posthog-rails/lib/posthog/rails.rb @@ -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' diff --git a/posthog-rails/lib/posthog/rails/action_cable.rb b/posthog-rails/lib/posthog/rails/action_cable.rb index 81b6b7dd..3960f01f 100644 --- a/posthog-rails/lib/posthog/rails/action_cable.rb +++ b/posthog-rails/lib/posthog/rails/action_cable.rb @@ -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 @@ -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 @@ -213,7 +225,7 @@ def perform_action(data) end end - private_constant :ActionCableCapture, :ActionCableContext, + private_constant :ActionCableExceptionContext, :ActionCableCapture, :ActionCableContext, :ActionCableConnectionExtensions, :ActionCableChannelExtensions, :ActionCableDispatch, :ActionCableSubscriptionsExtensions end diff --git a/posthog-rails/lib/posthog/rails/capture_exceptions.rb b/posthog-rails/lib/posthog/rails/capture_exceptions.rb index 6cbd8657..c95d8541 100644 --- a/posthog-rails/lib/posthog/rails/capture_exceptions.rb +++ b/posthog-rails/lib/posthog/rails/capture_exceptions.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'posthog/rails/parameter_filter' +require 'posthog/rails/exception_context' module PostHog module Rails @@ -8,8 +8,6 @@ module Rails # # @api private class CaptureExceptions - include ParameterFilter - # @param app [#call] Rack application. def initialize(app) @app = app @@ -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, @@ -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 diff --git a/posthog-rails/lib/posthog/rails/controller_helpers.rb b/posthog-rails/lib/posthog/rails/controller_helpers.rb new file mode 100644 index 00000000..87642ffa --- /dev/null +++ b/posthog-rails/lib/posthog/rails/controller_helpers.rb @@ -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) + 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) + rescue StandardError + PostHog::Logging.logger.error('Failed to capture controller exception') + false + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/exception_context.rb b/posthog-rails/lib/posthog/rails/exception_context.rb new file mode 100644 index 00000000..11bddd7f --- /dev/null +++ b/posthog-rails/lib/posthog/rails/exception_context.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'posthog/rails/parameter_filter' +require 'posthog/rails/request_metadata' + +module PostHog + module Rails + # Shared request enrichment for automatic and manual exception capture. + # + # @api private + class ExceptionContext + include ParameterFilter + + def initialize(request, controller = request.env['action_controller.instance'], manual: false) + @request = request + @controller = controller + @manual = manual + end + + def distinct_id + # 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(@controller) + user_id = extract_user_id(user) if user + return user_id if present?(user_id) + + nil + rescue StandardError + raise unless @manual + + nil + end + + def properties + request = @request + env = request.env + properties = @manual ? RequestMetadata.extract(request) : {} + + # Add controller and action if available + if @controller + controller = @controller + properties['$controller'] = safe_serialize(controller.controller_name) + properties['$action'] = safe_serialize(controller.action_name) + end + + # Add request parameters (be careful with sensitive data) + params = request_parameters + if params.present? + filtered_params = filter_request_parameters(params) + # Safe serialize to handle any complex objects in params + properties['$request_params'] = safe_serialize(filtered_params) unless filtered_params.empty? + end + + status = env['posthog.response_status_code'] + status ||= @controller.response.status if @manual && @controller&.performed? + properties['$response_status_code'] = status if status + + # Add referrer + properties['$referrer'] = safe_serialize(request.referrer) if request.referrer + + if @manual + %w[$current_url $referrer].each do |key| + properties[key] = properties[key].split(/[?#]/, 2).first if properties[key] + end + end + + properties + rescue StandardError + raise unless @manual + + {} + end + + private + + def request_parameters + return @request.params unless @manual + + # Only Rails-owned parsed caches are safe to read without invoking body-parser diagnostics. + cached = @request.get_header('action_dispatch.request.parameters') + return cached if cached.is_a?(Hash) + + body = @request.get_header('action_dispatch.request.request_parameters') + body = {} unless body.is_a?(Hash) + body.merge(@request.query_parameters).merge(@request.path_parameters) + rescue StandardError + raise unless @manual + + {} + end + + def filter_request_parameters(params) + if @manual && @request.has_header?('action_dispatch.parameter_filter') + ParameterFilter.backend.new(@request.get_header('action_dispatch.parameter_filter')).filter(params) + else + filter_sensitive_params(params) + end + 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 + message = @manual ? 'current_user_resolver failed' : "current_user_resolver failed: #{e.message}" + PostHog::Logging.logger.warn(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 present?(value) + !(value.nil? || (value.respond_to?(:empty?) && value.empty?)) + end + end + + private_constant :ExceptionContext + end +end diff --git a/posthog-rails/lib/posthog/rails/railtie.rb b/posthog-rails/lib/posthog/rails/railtie.rb index 2c38c33d..6006dc9b 100644 --- a/posthog-rails/lib/posthog/rails/railtie.rb +++ b/posthog-rails/lib/posthog/rails/railtie.rb @@ -11,6 +11,13 @@ class Railtie < ::Rails::Railtie PostHog::Rails.install_posthog_facade! end + initializer 'posthog.controller_helpers' do + ActiveSupport.on_load(:action_controller) do + include PostHog::Rails::ControllerHelpers unless method_defined?(:capture_exception) || + private_method_defined?(:capture_exception) + end + end + # Insert middleware for request context and exception capturing initializer 'posthog.insert_middlewares' do |app| # Wrap the Rails exception middleware so request context is active for diff --git a/posthog-rails/lib/posthog/rails/streaming_exceptions.rb b/posthog-rails/lib/posthog/rails/streaming_exceptions.rb index 2a65b93a..6e8394ed 100644 --- a/posthog-rails/lib/posthog/rails/streaming_exceptions.rb +++ b/posthog-rails/lib/posthog/rails/streaming_exceptions.rb @@ -79,12 +79,13 @@ def report(exception, source:, controller:) private def capture_streaming_exception(exception, source, env = nil) - properties = env ? build_properties(ActionDispatch::Request.new(env), env) : {} + context = ExceptionContext.new(ActionDispatch::Request.new(env)) if env + properties = context ? context.properties : {} properties['$exception_source'] = source properties['$exception_level'] = 'error' PostHog.capture_exception( exception, - env ? extract_distinct_id(env) : nil, + context&.distinct_id, properties, mechanism: { 'type' => 'middleware', 'handled' => false } ) diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index cd787d17..80df9bcd 100644 --- a/public_api_snapshot.txt +++ b/public_api_snapshot.txt @@ -399,6 +399,7 @@ instance_method PostHog::Rails::Configuration#use_tracing_headers=(arg0) instance_method PostHog::Rails::Configuration#user_id_method() instance_method PostHog::Rails::Configuration#user_id_method=(arg0) constant PostHog::Rails::Configuration::DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE: Integer +module PostHog::Rails::ControllerHelpers class PostHog::Rails::InitConfig instance_method PostHog::Rails::InitConfig#api_key=(value) instance_method PostHog::Rails::InitConfig#before_send=(value) diff --git a/spec/posthog/rails/action_cable_spec.rb b/spec/posthog/rails/action_cable_spec.rb index b5dce7f5..b3700723 100644 --- a/spec/posthog/rails/action_cable_spec.rb +++ b/spec/posthog/rails/action_cable_spec.rb @@ -98,6 +98,11 @@ def speak(data) ) end + def captured_event + expect(client.queued_messages).to be_positive + client.dequeue_last_message + end + def expect_original_error(&block) expect(&block).to(raise_error { |raised| expect(raised).to equal(error) }) end @@ -113,7 +118,7 @@ def expect_original_error(&block) expect(context_class.current).to equal(outer) end expect(client.queued_messages).to eq(1) - event = client.dequeue_last_message + event = captured_event expect(event[:distinct_id]).to eq('browser-user') expect(event[:properties]).to include( '$session_id' => 'browser-session', '$request_path' => '/cable', @@ -139,7 +144,7 @@ def expect_original_error(&block) end end expect(client.queued_messages).to eq(1) - properties = client.dequeue_last_message[:properties] + properties = captured_event[:properties] expect(properties).to include( '$exception_source' => 'rails.action_cable', '$action_cable_operation' => operation, '$channel_class' => 'PostHogCableChannel', '$connection_class' => 'PostHogCableConnection', @@ -164,7 +169,7 @@ def expect_original_error(&block) socket_channel = channel_class.new(connection, 'id', payload) channel_class.failure = error expect_original_error { socket_channel.perform_action(payload.merge('action' => 'speak')) } - properties = client.dequeue_last_message[:properties] + properties = captured_event[:properties] expected = { 'password' => '[FILTERED]', 'credential' => '[FILTERED]', 'nested' => [{ 'secret' => '[FILTERED]', 'pin' => '[CABLE FILTERED]', 'visible' => 'ok' }] } aggregate_failures do @@ -183,7 +188,7 @@ def expect_original_error(&block) channel_class.failure = error socket_channel = channel_class.new(connection, 'id', 'pin' => '1234') expect_original_error { socket_channel.perform_action('action' => 'speak', 'pin' => '5678') } - properties = client.dequeue_last_message[:properties] + properties = captured_event[:properties] aggregate_failures do expect(properties['$channel_params']['pin']).to eq('4321') expect(properties['$action_data']['pin']).to eq('8765') @@ -195,7 +200,7 @@ def expect_original_error(&block) allow(Rails).to receive(:application).and_return(nil) channel_class.failure = error expect_original_error { channel.perform_action('action' => 'speak', 'credential' => 'private') } - expect(client.dequeue_last_message[:properties]['$action_data']['credential']).to eq('[FILTERED]') + expect(captured_event[:properties]['$action_data']['credential']).to eq('[FILTERED]') allow(connection).to receive(:config).and_return(Object.new) app_filter = ->(key, value) { value.replace('[APP FILTERED]') if key == 'pin' } @@ -204,7 +209,7 @@ def expect_original_error(&block) ) channel_class.failure = RuntimeError.new('next failure') expect { channel.perform_action('action' => 'speak', 'pin' => '1234') }.to raise_error('next failure') - expect(client.dequeue_last_message[:properties]['$action_data']['pin']).to eq('[APP FILTERED]') + expect(captured_event[:properties]['$action_data']['pin']).to eq('[APP FILTERED]') end describe 'subscription command dispatch' do @@ -236,7 +241,7 @@ def install_connection_handler(&block) end expect(seen).to eq([error]) expect(client.queued_messages).to eq(1) - event = client.dequeue_last_message + event = captured_event expect(event[:event]).to eq('handled cable error') expect(event[:distinct_id]).to eq('browser-user') expect(event[:properties]).to include('$session_id' => 'browser-session', '$request_path' => '/cable') @@ -259,7 +264,7 @@ def install_connection_handler(&block) channel_class.failure = error execute('message', 'action' => 'speak', 'password' => 'private') expect(client.queued_messages).to eq(1) - event = client.dequeue_last_message + event = captured_event expect(event[:distinct_id]).to eq('browser-user') expect(event[:properties]).to include('$channel_class' => 'PostHogCableChannel', '$action' => 'speak', '$action_cable_operation' => 'action', '$session_id' => 'browser-session') @@ -273,7 +278,7 @@ def install_connection_handler(&block) execute('subscribe') channel_class.failure = error execute('message', 'action' => 'speak') - event = client.dequeue_last_message + event = captured_event expect(event[:distinct_id]).to eq('signed-in') expect(event[:properties]['$session_id']).to eq('browser-session') end @@ -309,7 +314,7 @@ def install_connection_handler(&block) 2.times { proceed << true } results.each(&:value) expect(client.queued_messages).to eq(1) - event = client.dequeue_last_message + event = captured_event expect(event[:distinct_id]).to eq('escaping') expect(event[:properties]['$action']).to eq('speak') expect(context_class.current).to be_nil @@ -329,7 +334,7 @@ def install_connection_handler(&block) ) expect(seen).to eq([error]) expect(client.queued_messages).to eq(1) - event = client.dequeue_last_message + event = captured_event expect(event[:properties]['$exception_list'].first).to include('value' => 'handler failed') expect(event[:properties]['$exception_list'].first['mechanism']).to include('handled' => false) expect(context_class.current).to be_nil @@ -360,7 +365,7 @@ def install_connection_handler(&block) execute('subscribe') channel_class.failure = error execute('message', 'action' => 'speak') - client.dequeue_last_message + captured_event channel_class.failure = nil expect(execute('message', 'action' => 'speak')).to eq(:spoken) expect(client.queued_messages).to eq(0) @@ -430,12 +435,12 @@ def install_connection_handler(&block) env['action_controller.instance'] = double(current_user: double(id: 'authenticated')) channel_class.failure = error expect_original_error { channel.perform_action('action' => 'speak') } - expect(client.dequeue_last_message[:distinct_id]).to eq('authenticated') + expect(captured_event[:distinct_id]).to eq('authenticated') PostHog::Rails.config.capture_user_context = false PostHog::Rails.config.use_tracing_headers = false channel_class.failure = RuntimeError.new('again') expect { channel.perform_action('action' => 'speak') }.to raise_error('again') - event = client.dequeue_last_message + event = captured_event expect(event[:distinct_id]).not_to eq('browser-user') expect(event[:properties]).not_to have_key('$session_id') end @@ -448,19 +453,19 @@ def install_connection_handler(&block) PostHog::Rails.config.current_user_resolver = ->(_controller) { raise 'controller required' } channel_class.failure = error expect_original_error { channel.perform_action('action' => 'speak') } - expect(client.dequeue_last_message[:distinct_id]).to eq('connection-user') + expect(captured_event[:distinct_id]).to eq('connection-user') PostHog::Rails.config.capture_user_context = false channel_class.failure = RuntimeError.new('next action') expect { channel.perform_action('action' => 'speak') }.to raise_error('next action') - expect(client.dequeue_last_message[:distinct_id]).to eq('browser-user') + expect(captured_event[:distinct_id]).to eq('browser-user') end it 'falls back to headers when the connection current_user method raises' do connection_class.class_eval { def current_user = raise('user unavailable') } channel_class.failure = error expect_original_error { channel.perform_action('action' => 'speak') } - expect(client.dequeue_last_message[:distinct_id]).to eq('browser-user') + expect(captured_event[:distinct_id]).to eq('browser-user') end it 'uses default current_user IDs and falls back when reading the user ID fails' do @@ -468,7 +473,7 @@ def install_connection_handler(&block) connection.current_user = Struct.new(:id).new('signed-in') channel_class.failure = error expect_original_error { channel.perform_action('action' => 'speak') } - expect(client.dequeue_last_message[:distinct_id]).to eq('signed-in') + expect(captured_event[:distinct_id]).to eq('signed-in') user = Object.new def user.id @@ -478,7 +483,7 @@ def user.id channel_class.failure = RuntimeError.new('next failure') expect { channel.perform_action('action' => 'speak') }.to raise_error('next failure') expect(client.queued_messages).to eq(1) - expect(client.dequeue_last_message[:distinct_id]).to eq('browser-user') + expect(captured_event[:distinct_id]).to eq('browser-user') end it 'does not duplicate captures when a channel failure escapes connection close' do @@ -486,7 +491,7 @@ def user.id allow(connection.subscriptions).to receive(:unsubscribe_from_all) { channel.unsubscribe_from_channel } expect_original_error { connection.send(:handle_close) } expect(client.queued_messages).to eq(1) - expect(client.dequeue_last_message[:properties]['$channel_class']).to eq('PostHogCableChannel') + expect(captured_event[:properties]['$channel_class']).to eq('PostHogCableChannel') end it 'keeps different socket actions isolated, including test connections without env' do @@ -496,8 +501,8 @@ def user.id [first_connection, missing_env_connection].each do |socket| channel_class.new(socket, '').perform_action('action' => 'speak') end - first = client.dequeue_last_message - second = client.dequeue_last_message + first = captured_event + second = captured_event expect(first[:distinct_id]).to eq('browser-user') expect(second[:distinct_id]).not_to eq('browser-user') expect(second[:properties]).not_to have_key('$session_id') @@ -510,7 +515,7 @@ def user.id expect_original_error do channel_class.new(socket, '', 'password' => 'private').perform_action('action' => 'speak') end - event = client.dequeue_last_message + event = captured_event expect(event[:properties]['$channel_params']['password']).to eq('[FILTERED]') expect(event[:properties]['$process_person_profile']).to be(false) expect(event[:properties]).not_to have_key('$session_id') @@ -549,7 +554,7 @@ def user.id expect(6.times.map { results.pop }).to contain_exactly( 'first', 'second', 'socket action failed', 'socket action failed', 'worker', 'worker' ) - expect(2.times.map { client.dequeue_last_message[:distinct_id] }).to match_array(%w[first second]) + expect(2.times.map { captured_event[:distinct_id] }).to match_array(%w[first second]) expect(context_class.current).to be_nil end diff --git a/spec/posthog/rails/controller_helpers_spec.rb b/spec/posthog/rails/controller_helpers_spec.rb new file mode 100644 index 00000000..0612bb26 --- /dev/null +++ b/spec/posthog/rails/controller_helpers_spec.rb @@ -0,0 +1,621 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rails' +require 'action_controller' +require 'rack/mock' +require 'open3' + +$LOAD_PATH.unshift File.expand_path('../../../posthog-rails/lib', __dir__) +require 'posthog/rails' + +RSpec.describe 'controller exception reporting' do + let(:client) { PostHog::Client.new(api_key: API_KEY, test_mode: true) } + let(:error) { StandardError.new('manual failure') } + let(:arguments) { [error] } + let(:keywords) { {} } + let(:controller_class) do + Class.new(ActionController::API) do + class << self + attr_accessor :report_arguments, :report_keywords, :report_result, :after_report, :reported_params + end + + def self.name + 'ReportsController' + end + + def create + self.class.report_result = capture_exception(*self.class.report_arguments, **self.class.report_keywords) + self.class.after_report&.call + self.class.reported_params = params.to_unsafe_h + head :ok + end + + private + + def current_user + { 'id' => 'authenticated-user' } + end + end + end + + before(:all) do + initializer = PostHog::Rails::Railtie.initializers.find { |entry| entry.name == 'posthog.controller_helpers' } + PostHog::Rails::Railtie.instance.instance_exec(&initializer.block) if initializer + end + + around do |example| + previous_config = PostHog::Rails.config + previous_client = PostHog.client + PostHog::Rails.config = PostHog::Rails::Configuration.new + PostHog::Logging.logger = Logger.new(File::NULL) + PostHog.client = client + example.run + ensure + PostHog.client = previous_client + PostHog::Rails.config = previous_config + client.shutdown + end + + def report(path = '/reports?password=secret&safe=value', headers = {}) + controller_class.report_arguments = arguments + controller_class.report_keywords = keywords + app = controller_class.action(:create) + app = PostHog::Rails::CaptureExceptions.new(app) + app = PostHog::Rails::RequestContext.new(app) + app.call(Rack::MockRequest.env_for(path, headers)) + end + + it 'reports handled errors even when automatic capture is off and returns the facade result' do + response = report + + expect(response.first).to eq(200) + expect(controller_class.report_result).to be(true) + event = client.dequeue_last_message + expect(event[:event]).to eq('$exception') + expect(event[:distinct_id]).to eq('authenticated-user') + expect(event[:properties]['$exception_list'].first['mechanism']).to include('type' => 'generic', 'handled' => true) + expect(event[:properties]).not_to have_key('$exception_source') + end + + it 'adds Rails-filtered request parameters and safe metadata without changing parameters' do + application = double('application', config: double('config', filter_parameters: [:password])) + allow(Rails).to receive(:application).and_return(application) + report('/reports?password=secret&safe=value', 'HTTP_USER_AGENT' => 'Test Agent') + + properties = client.dequeue_last_message[:properties] + expect(properties).to include( + '$request_params' => { 'password' => '[FILTERED]', 'safe' => 'value' }, + '$controller' => 'reports', '$action' => 'create', + '$request_path' => '/reports', '$request_method' => 'GET', '$user_agent' => 'Test Agent' + ) + expect(properties['$current_url']).not_to include('secret', '?') + expect(controller_class.reported_params).to include('password' => 'secret', 'safe' => 'value') + end + + ['?token=secret#private', '#private'].each do |suffix| + it "omits sensitive URL suffix #{suffix.inspect} from the helper-derived referrer" do + report('/reports?token=secret', 'HTTP_REFERER' => "https://example.com/source#{suffix}") + properties = client.dequeue_last_message[:properties] + expect(properties['$current_url']).to eq('http://example.org/reports') + expect(properties['$referrer']).to eq('https://example.com/source') + end + end + + it 'uses the configured user resolver and ID method before header identity' do + PostHog::Rails.config.current_user_resolver = -> { Struct.new(:analytics_id).new('configured-user') } + PostHog::Rails.config.user_id_method = :analytics_id + report('/reports', 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user') + + expect(client.dequeue_last_message[:distinct_id]).to eq('configured-user') + end + + it 'uses the configured private current user method' do + PostHog::Rails.config.current_user_method = :account + controller_class.class_eval do + private + + def account + { 'id' => 'account-user' } + end + end + report + expect(client.dequeue_last_message[:distinct_id]).to eq('account-user') + end + + it 'falls back to sanitized request identity and session when user context is disabled' do + PostHog::Rails.config.capture_user_context = false + report('/reports', 'HTTP_X_POSTHOG_DISTINCT_ID' => ' header-user ', 'HTTP_X_POSTHOG_SESSION_ID' => ' session-id ') + + event = client.dequeue_last_message + expect(event[:distinct_id]).to eq('header-user') + expect(event[:properties]['$session_id']).to eq('session-id') + end + + it 'falls back to request identity if the user resolver fails' do + PostHog::Rails.config.current_user_resolver = ->(_controller) { raise 'resolver failed' } + report('/reports', 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user') + expect(client.dequeue_last_message[:distinct_id]).to eq('header-user') + end + + it 'honors the tracing header switch and captures personlessly without an identity' do + PostHog::Rails.config.capture_user_context = false + PostHog::Rails.config.use_tracing_headers = false + report('/reports', 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user', 'HTTP_X_POSTHOG_SESSION_ID' => 'session-id') + + event = client.dequeue_last_message + expect(event[:distinct_id]).not_to eq('header-user') + expect(event[:properties]['$process_person_profile']).to be(false) + expect(event[:properties]).not_to have_key('$session_id') + end + + context 'with explicit arguments' do + let(:properties) do + { '$controller' => 'explicit-controller', '$request_path' => '/explicit', '$session_id' => 'explicit-session', + '$referrer' => 'explicit-referrer', '$trace_id' => 'explicit-trace', '$span_id' => 'explicit-span', + '$request_params' => { 'safe' => 'explicit' }, 'area' => 'checkout' }.freeze + end + let(:arguments) { [error, 'explicit-user', properties] } + let(:flags) do + record = PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord.new(key: 'checkout', enabled: true) + PostHog::FeatureFlagEvaluations.new(distinct_id: 'explicit-user', flags: { 'checkout' => record }) + end + let(:keywords) { { flags: flags, mechanism: { 'type' => 'custom', 'handled' => false } } } + + it 'preserves explicit identity, properties, flags, mechanism, and the input property bag' do + allow(PostHog).to receive(:capture_exception).and_call_original + resolved_user = false + PostHog::Rails.config.current_user_resolver = -> { resolved_user = true } + report('/reports', 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user', 'HTTP_X_POSTHOG_SESSION_ID' => 'header-session') + + event = client.dequeue_last_message + expect(event[:distinct_id]).to eq('explicit-user') + expect(resolved_user).to be(false) + expect(event[:properties]).to include(properties) + expect(event[:properties]).to include('$feature/checkout' => true, '$active_feature_flags' => ['checkout']) + expect(event[:properties]['$exception_list'].first['mechanism']).to include('type' => 'custom', + 'handled' => false) + expect(PostHog).to have_received(:capture_exception).with(error, 'explicit-user', hash_including(properties), + **keywords) + end + end + + it 'captures from a full Rails controller as well as API controllers' do + full_controller = Class.new(ActionController::Base) do + def self.name + 'FullReportsController' + end + + def create + capture_exception(StandardError.new('full controller')) + head :ok + end + end + full_controller.action(:create).call(Rack::MockRequest.env_for('/full')) + expect(client.dequeue_last_message[:properties]).to include('$controller' => 'full_reports', + '$request_path' => '/full') + end + + it 'falls back to request identity when there is no authenticated user' do + PostHog::Rails.config.current_user_resolver = ->(_controller) {} + report('/reports', 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user') + expect(client.dequeue_last_message[:distinct_id]).to eq('header-user') + end + + it 'captures despite user enrichment failures without replacing application errors or logging secrets' do + diagnostics = StringIO.new + PostHog::Logging.logger = Logger.new(diagnostics) + controller_class.class_eval do + private + + def current_user + raise 'PRIVATE_PROBE user lookup failed' + end + end + controller_class.after_report = -> { raise error } + expect { report }.to(raise_error { |raised| expect(raised).to equal(error) }) + expect(controller_class.report_result).to be(true) + expect(client.queued_messages).to eq(1) + expect(diagnostics.string).not_to include('PRIVATE_PROBE') + end + + context 'with body parameters and completed responses' do + let(:diagnostics) { StringIO.new } + let(:parser_calls) { [] } + let(:controller_class) do + Class.new(super()) do + class << self + attr_accessor :prepare_report, :diagnostics, :helper_diagnostics, :input_positions + end + + def create + self.class.prepare_report.call(self) + diagnostic_offset = self.class.diagnostics.string.length + input = request.env['rack.input'] + position = input.pos + self.class.report_result = capture_exception(*self.class.report_arguments, **self.class.report_keywords) + self.class.input_positions = [position, input.pos] + self.class.helper_diagnostics = self.class.diagnostics.string[diagnostic_offset..] + head :ok unless performed? + end + + def control + head :ok + end + end + end + + before do + logger = Logger.new(diagnostics) + controller_class.logger = logger + controller_class.diagnostics = diagnostics + PostHog::Logging.logger = logger + controller_class.prepare_report = lambda do |controller| + allow(controller.request).to receive(:request_parameters).and_wrap_original do |original, *args| + parser_calls << true + original.call(*args) + end + end + end + + def dispatch_body(body = '', action: :create, query: 'safe=query', headers: {}) + controller_class.report_arguments = arguments + controller_class.report_keywords = keywords + env = Rack::MockRequest.env_for("/reports?#{query}", method: 'POST', input: body, + 'CONTENT_TYPE' => 'application/json') + env.update(headers) + env['action_dispatch.logger'] = controller_class.logger + env['action_dispatch.request.path_parameters'] = { id: 'route-id' } + app = PostHog::Rails::RequestContext.new( + PostHog::Rails::CaptureExceptions.new(controller_class.action(action)) + ) + [app.call(env), env] + end + + it 'captures malformed JSON without helper-owned parsing, raw diagnostics, or body position changes' do + body = '{"token":"PRIVATE_PROBE"' + dispatch_body(body, action: :control) + control_logged_body = diagnostics.string.include?('PRIVATE_PROBE') + diagnostics.truncate(0) + diagnostics.rewind + response, = dispatch_body(body) + + expect(response.first).to eq(200) + expect(controller_class.report_result).to be(true) + expect(parser_calls).to be_empty + expect(controller_class.helper_diagnostics).not_to include('PRIVATE_PROBE') + expect(controller_class.input_positions.uniq.length).to eq(1) + expect(diagnostics.string.include?('PRIVATE_PROBE')).to eq(control_logged_body) + event = client.dequeue_last_message + expect(event[:properties]['$exception_list'].first['value']).to eq(error.message) + expect(event[:properties]['$request_params']).to include('safe' => 'query', 'id' => 'route-id') + expect(event[:properties].to_s).not_to include('PRIVATE_PROBE') + end + + ['action_dispatch.request.parameters', 'action_dispatch.request.request_parameters'].each do |cache_key| + it "retains filtered valid parameters using the #{cache_key} cache without reparsing or mutation" do + application = double('application', config: double('config', filter_parameters: [:token])) + allow(Rails).to receive(:application).and_return(application) + original_prepare = controller_class.prepare_report + original_parameters = nil + controller_class.prepare_report = lambda do |controller| + original_prepare.call(controller) + if cache_key.end_with?('request_parameters') + controller.request.delete_header('action_dispatch.request.parameters') + end + original_parameters = controller.request.get_header(cache_key).deep_dup + end + response, env = dispatch_body('{"token":"PRIVATE_PROBE","item":"body"}') + + expect(response.first).to eq(200) + expect(controller_class.report_result).to be(true) + expect(parser_calls).to be_empty + expect(controller_class.input_positions.uniq.length).to eq(1) + expect(env[cache_key]).to eq(original_parameters) + expect(env[cache_key]['token']).to eq('PRIVATE_PROBE') + expect(client.dequeue_last_message[:properties]['$request_params']).to include( + 'token' => '[FILTERED]', 'item' => 'body', 'safe' => 'query', 'id' => 'route-id' + ) + end + end + + ['action_dispatch.request.parameters', 'action_dispatch.request.request_parameters', nil] + .product(%i[public private]).each do |cache_key, visibility| + it "honors #{visibility} request-specific parameter filters with #{cache_key || 'an uncached body'}" do + application = double('application', config: double('config', filter_parameters: [:password])) + allow(Rails).to receive(:application).and_return(application) + original_prepare = controller_class.prepare_report + original_parameters = nil + controller_class.prepare_report = lambda do |controller| + original_prepare.call(controller) + controller.request.singleton_class.send(visibility, :parameter_filter) + expect(controller.request.respond_to?(:parameter_filter)).to eq(visibility == :public) + controller.request.delete_header('action_dispatch.request.parameters') unless + cache_key == 'action_dispatch.request.parameters' + controller.request.delete_header('action_dispatch.request.request_parameters') unless cache_key + original_parameters = controller.request.get_header(cache_key).deep_dup if cache_key + end + response, env = dispatch_body( + '{"person":{"ssn":"PRIVATE_BODY"},"item":"body"}', + query: 'ssn=PRIVATE_QUERY&password=PRIVATE_PASSWORD&safe=query', + headers: { 'action_dispatch.parameter_filter' => %i[password ssn] } + ) + + expect(response.first).to eq(200) + expect(controller_class.report_result).to be(true) + expect(parser_calls).to be_empty + expect(controller_class.input_positions.uniq.length).to eq(1) + expect(env[cache_key]).to eq(original_parameters) if cache_key + properties = client.dequeue_last_message[:properties] + expect(properties['$request_params']).to include( + 'ssn' => '[FILTERED]', 'password' => '[FILTERED]', 'safe' => 'query' + ) + if cache_key + expect(properties['$request_params']).to include('person' => { 'ssn' => '[FILTERED]' }, 'item' => 'body') + end + expect(properties['$request_params'].to_s).not_to include('PRIVATE_') + expect(env).not_to have_key('action_dispatch.request.request_parameters') unless cache_key + end + end + + it 'does not proactively parse a valid uncached body' do + original_prepare = controller_class.prepare_report + controller_class.prepare_report = lambda do |controller| + original_prepare.call(controller) + controller.request.delete_header('action_dispatch.request.parameters') + controller.request.delete_header('action_dispatch.request.request_parameters') + end + _, env = dispatch_body('{"token":"PRIVATE_PROBE","item":"body"}') + + expect(controller_class.report_result).to be(true) + expect(parser_calls).to be_empty + expect(env).not_to have_key('action_dispatch.request.request_parameters') + expect(client.dequeue_last_message[:properties]['$request_params']).to eq('safe' => 'query', 'id' => 'route-id') + expect(controller_class.input_positions.uniq.length).to eq(1) + end + + it 'still captures when a configured parameter filter fails without logging its sensitive error' do + filter = ->(_key, _value) { raise 'PRIVATE_PROBE filter failed' } + application = double('application', config: double('config', filter_parameters: [filter])) + allow(Rails).to receive(:application).and_return(application) + dispatch_body('{"token":"PRIVATE_PROBE"}') + + expect(controller_class.report_result).to be(true) + expect(controller_class.helper_diagnostics).not_to include('PRIVATE_PROBE') + properties = client.dequeue_last_message[:properties] + expect(properties['$exception_list'].first['value']).to eq(error.message) + expect(properties).not_to have_key('$request_params') + end + + it 'still captures when request URL metadata has invalid encoding' do + controller_class.prepare_report = lambda do |controller| + controller.request.set_header('HTTP_REFERER', "\xFF") + end + dispatch_body + expect(controller_class.report_result).to be(true) + expect(client.dequeue_last_message[:properties]).not_to have_key('$referrer') + end + + it 'does not log a failing configured user resolver message' do + PostHog::Rails.config.current_user_resolver = -> { raise 'PRIVATE_PROBE resolver failed' } + dispatch_body + expect(controller_class.report_result).to be(true) + expect(controller_class.helper_diagnostics).not_to include('PRIVATE_PROBE') + expect(client.queued_messages).to eq(1) + end + + it 'leaves automatic middleware collection of uncached body parameters unchanged' do + PostHog::Rails.config.auto_capture_exceptions = true + application = double('application', config: double('config', filter_parameters: [:token])) + allow(Rails).to receive(:application).and_return(application) + env = Rack::MockRequest.env_for('/automatic?safe=query', method: 'POST', + input: '{"token":"PRIVATE_PROBE","item":"body"}', + 'CONTENT_TYPE' => 'application/json') + allow(Rails).to receive(:root).and_return(nil) + middleware = PostHog::Rails::CaptureExceptions.new(->(_env) { raise error }) + + expect { middleware.call(env) }.to(raise_error { |raised| expect(raised).to equal(error) }) + expect(client.dequeue_last_message[:properties]['$request_params']).to include( + 'token' => '[FILTERED]', 'item' => 'body', 'safe' => 'query' + ) + end + + it 'includes a response status already performed by the controller' do + original_prepare = controller_class.prepare_report + controller_class.prepare_report = lambda do |controller| + original_prepare.call(controller) + controller.head :unprocessable_entity + end + response, = dispatch_body + + expect(response.first).to eq(422) + expect(client.dequeue_last_message[:properties]['$response_status_code']).to eq(422) + end + + it 'does not invent the default status before the controller performs a response' do + response, = dispatch_body + expect(response.first).to eq(200) + expect(client.dequeue_last_message[:properties]).not_to have_key('$response_status_code') + end + + context 'with an explicit response status property' do + let(:arguments) { [error, nil, { '$response_status_code' => 409 }] } + + it 'keeps explicit status precedence over a performed response' do + controller_class.prepare_report = ->(controller) { controller.head :unprocessable_entity } + response, = dispatch_body + expect(response.first).to eq(422) + expect(client.dequeue_last_message[:properties]['$response_status_code']).to eq(409) + end + end + + it 'does not log sensitive error messages when the facade raises' do + allow(PostHog).to receive(:capture_exception).and_raise(StandardError, 'PRIVATE_PROBE capture failed') + dispatch_body + expect(controller_class.report_result).to be(false) + expect(controller_class.helper_diagnostics).not_to include('PRIVATE_PROBE') + expect(client.queued_messages).to eq(0) + end + end + + it 'derives trace IDs from an active optional OpenTelemetry span' do + begin + require 'opentelemetry-api' + rescue LoadError + skip 'Install the optional bundle to test active span correlation' + end + span_context = OpenTelemetry::Trace::SpanContext.new( + trace_id: ['00000000000000000000000000000001'].pack('H*'), + span_id: ['0000000000000002'].pack('H*') + ) + span = OpenTelemetry::Trace.non_recording_span(span_context) + OpenTelemetry::Trace.with_span(span) { report } + expect(client.dequeue_last_message[:properties]).to include( + '$trace_id' => '00000000000000000000000000000001', '$span_id' => '0000000000000002' + ) + end + + it 'does not apply automatic exception exclusions to manual reports' do + PostHog::Rails.config.excluded_exceptions = ['StandardError'] + report + expect(client.queued_messages).to eq(1) + end + + it 'preserves deduplication when the manually reported error escapes the controller' do + PostHog::Rails.config.auto_capture_exceptions = true + controller_class.after_report = -> { raise error } + + expect { report }.to(raise_error { |raised| expect(raised).to equal(error) }) + expect(client.queued_messages).to eq(1) + end + + it 'returns false for a previously captured exception' do + client.capture_exception(error) + report + expect(controller_class.report_result).to be(false) + expect(client.queued_messages).to eq(1) + end + + context 'with an unsupported exception input' do + let(:arguments) { [nil, nil, nil] } + + it 'preserves the nil core result and accepts nil additional properties' do + report + expect(controller_class.report_result).to be_nil + expect(client.queued_messages).to eq(0) + end + end + + it 'preserves false when before_send drops the report' do + PostHog.client = PostHog::Client.new(api_key: API_KEY, test_mode: true, before_send: ->(_event) {}) + report + expect(controller_class.report_result).to be(false) + expect(PostHog.client.queued_messages).to eq(0) + ensure + PostHog.client.shutdown + end + + it 'does not resolve users or enqueue events with a disabled client' do + PostHog.client = PostHog::Client.new(api_key: nil, silence_disabled_client_error: true) + resolved_user = false + PostHog::Rails.config.current_user_resolver = -> { resolved_user = true } + report + expect(resolved_user).to be(false) + expect(controller_class.report_result).to be(false) + expect(PostHog.client.queued_messages).to eq(0) + ensure + PostHog.client.shutdown + end + + it 'does not create a client when reporting before initialization' do + PostHog.client = nil + expect(PostHog::Client).not_to receive(:new) + report + expect(controller_class.report_result).to be(false) + expect(PostHog.client).to be_nil + end + + it 'does not leak controller user or request context to subsequent captures' do + report('/first', 'HTTP_X_POSTHOG_SESSION_ID' => 'first-session') + client.dequeue_last_message + client.capture_exception(StandardError.new('outside')) + event = client.dequeue_last_message + expect(event[:distinct_id]).not_to eq('authenticated-user') + expect(event[:properties]).not_to have_key('$session_id') + expect(event[:properties]).not_to have_key('$request_path') + expect(event[:properties]).not_to have_key('$request_params') + end + + it 'keeps the helper private and out of controller actions' do + expect(controller_class.private_method_defined?(:capture_exception)).to be(true) + expect(controller_class.instance_method(:capture_exception).parameters).to eq( + [%i[req exception], %i[opt distinct_id], %i[opt additional_properties], %i[key flags], %i[key mechanism]] + ) + expect(controller_class.action_methods).not_to include('capture_exception') + expect(ActionController::Base.private_method_defined?(:capture_exception)).to be(true) + expect(ActionController::Base.action_methods).not_to include('capture_exception') + end + + it 'does not override application methods' do + controller_class.class_eval do + private + + def capture_exception(*) + :application_result + end + end + report + expect(controller_class.report_result).to eq(:application_result) + expect(client.queued_messages).to eq(0) + end + + it 'does not override inherited helpers installed before Rails initialization' do + script = <<~RUBY + require 'posthog' + require 'rails' + require 'action_controller/railtie' + require 'posthog/rails' + existing_helpers = Module.new do + private + def capture_exception(*) + :existing + end + end + [ActionController::Base, ActionController::API].each { |controller| controller.include(existing_helpers) } + class ExistingHelperApp < Rails::Application + config.eager_load = false + config.secret_key_base = 'test-secret' + config.logger = Logger.new(File::NULL) + end + ExistingHelperApp.initialize! + [ActionController::Base, ActionController::API].each do |controller| + abort 'overrode existing helper' unless controller.new.send(:capture_exception, StandardError.new) == :existing + end + RUBY + stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-Ilib', '-Iposthog-rails/lib', '-e', script) + expect(status).to be_success, "#{stdout}\n#{stderr}" + end + + it 'installs lazily during real Rails boot without loading optional controllers' do + script = <<~RUBY + require 'posthog' + require 'rails' + require 'posthog/rails' + abort 'loaded controllers eagerly' if defined?(ActionController::Base) || defined?(ActionController::API) + require 'action_controller/railtie' + class HelperApp < Rails::Application + config.eager_load = false + config.secret_key_base = 'test-secret' + config.logger = Logger.new(File::NULL) + end + HelperApp.initialize! + require 'action_controller' + [ActionController::Base, ActionController::API].each do |controller| + abort 'missing private helper' unless controller.private_method_defined?(:capture_exception) + abort 'helper is an action' if controller.action_methods.include?('capture_exception') + end + RUBY + stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-Ilib', '-Iposthog-rails/lib', '-e', script) + expect(status).to be_success, "#{stdout}\n#{stderr}" + end +end