diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a37693..1cbf611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 3.5.3 - 2026-02-08 + +1. fix: Fix Railtie middleware insertion crashing on Rails initialization — changed `insert_middleware_after` from a class method to an instance method (matching how Rails executes initializer blocks via `instance_exec`), and removed the unsupported `include?` query on `MiddlewareStackProxy` ([#97](https://github.com/PostHog/posthog-ruby/issues/97)) +2. fix: Prevent sending empty batches and handle non-JSON response bodies gracefully in transport layer ([#87](https://github.com/PostHog/posthog-ruby/issues/87)) +3. fix: Use `$current_url` property (instead of `$request_url`) so exception URLs appear correctly in the PostHog UI +4. fix: Only include source context lines for in-app exception frames, avoiding unnecessary reads of gem source files ([#88](https://github.com/PostHog/posthog-ruby/issues/88)) + ## 3.5.2 - 2026-02-06 1. fix: Filter out failed flag evaluations to prevent cached values from being overwritten during transient server errors ([#96](https://github.com/PostHog/posthog-ruby/pull/96)) diff --git a/Gemfile b/Gemfile index 9291bc8..4aa32b8 100644 --- a/Gemfile +++ b/Gemfile @@ -11,6 +11,7 @@ group :development, :test do gem 'commander', '~> 5.0' gem 'oj', '~> 3.16.10' gem 'prettier' + gem 'railties', '~> 7.1' gem 'rake', '~> 13.2.1' gem 'rspec', '~> 3.13' gem 'rubocop', '~> 1.75.6' diff --git a/lib/posthog/exception_capture.rb b/lib/posthog/exception_capture.rb index 3bccfef..73976de 100644 --- a/lib/posthog/exception_capture.rb +++ b/lib/posthog/exception_capture.rb @@ -67,7 +67,7 @@ def self.parse_backtrace_line(line) 'platform' => 'ruby' } - add_context_lines(frame, file, lineno) if File.exist?(file) + add_context_lines(frame, file, lineno) if frame['in_app'] && File.exist?(file) frame end diff --git a/lib/posthog/send_worker.rb b/lib/posthog/send_worker.rb index 589f5b9..5e1fa8f 100644 --- a/lib/posthog/send_worker.rb +++ b/lib/posthog/send_worker.rb @@ -43,8 +43,10 @@ def run consume_message_from_queue! until @batch.full? || @queue.empty? end - res = @transport.send @api_key, @batch - @on_error.call(res.status, res.error) unless res.status == 200 + unless @batch.empty? + res = @transport.send @api_key, @batch + @on_error.call(res.status, res.error) unless res.status == 200 + end @lock.synchronize { @batch.clear } end diff --git a/lib/posthog/transport.rb b/lib/posthog/transport.rb index cc9bf52..77ea1ec 100644 --- a/lib/posthog/transport.rb +++ b/lib/posthog/transport.rb @@ -50,7 +50,12 @@ def send(api_key, batch) last_response, exception = retry_with_backoff(@retries) do status_code, body = send_request(api_key, batch) - error = JSON.parse(body)['error'] + error = + begin + JSON.parse(body)['error'] + rescue JSON::ParserError + body + end should_retry = should_retry_request?(status_code, body) logger.debug("Response status code: #{status_code}") logger.debug("Response error: #{error}") if error diff --git a/lib/posthog/version.rb b/lib/posthog/version.rb index 0a5f628..00464c0 100644 --- a/lib/posthog/version.rb +++ b/lib/posthog/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module PostHog - VERSION = '3.5.2' + VERSION = '3.5.3' end diff --git a/posthog-rails/lib/posthog/rails/capture_exceptions.rb b/posthog-rails/lib/posthog/rails/capture_exceptions.rb index c17534e..657ab0e 100644 --- a/posthog-rails/lib/posthog/rails/capture_exceptions.rb +++ b/posthog-rails/lib/posthog/rails/capture_exceptions.rb @@ -99,7 +99,7 @@ def extract_user_id(user) def build_properties(request, env) properties = { '$exception_source' => 'rails', - '$request_url' => safe_serialize(request.url), + '$current_url' => safe_serialize(request.url), '$request_method' => safe_serialize(request.method), '$request_path' => safe_serialize(request.path) } diff --git a/posthog-rails/lib/posthog/rails/railtie.rb b/posthog-rails/lib/posthog/rails/railtie.rb index 292731d..62bb72a 100644 --- a/posthog-rails/lib/posthog/rails/railtie.rb +++ b/posthog-rails/lib/posthog/rails/railtie.rb @@ -111,13 +111,11 @@ def ensure_initialized! at_exit { PostHog.client&.shutdown if PostHog.initialized? } end - def self.insert_middleware_after(app, target, middleware) - if app.config.middleware.include?(target) - app.config.middleware.insert_after(target, middleware) - else - # Fallback: append to stack if target middleware is missing (e.g., API-only apps) - app.config.middleware.use(middleware) - end + def insert_middleware_after(app, target, middleware) + # During initialization, app.config.middleware is a MiddlewareStackProxy + # which only supports recording operations (insert_after, use, etc.) + # and does NOT support query methods like include?. + app.config.middleware.insert_after(target, middleware) end def self.register_error_subscriber diff --git a/spec/posthog/exception_capture_spec.rb b/spec/posthog/exception_capture_spec.rb index daef028..8136fac 100644 --- a/spec/posthog/exception_capture_spec.rb +++ b/spec/posthog/exception_capture_spec.rb @@ -25,6 +25,28 @@ module PostHog expect(frame['in_app']).to be false end + + it 'does not add context lines for non-in_app frames' do + # Use a gem-style path that points to this real file so File.exist? would be true + # but in_app should be false, so context lines should not be added + gem_line = "#{__FILE__}:10:in `gem_method'" + .gsub(%r{/spec/}, '/gems/ruby/spec/') + frame = described_class.parse_backtrace_line(gem_line) + + expect(frame['in_app']).to be false + expect(frame['context_line']).to be_nil + expect(frame['pre_context']).to be_nil + expect(frame['post_context']).to be_nil + end + + it 'adds context lines for in_app frames' do + # Use a real in_app path so File.exist? is true and in_app is true + app_line = "#{__FILE__}:10:in `app_method'" + frame = described_class.parse_backtrace_line(app_line) + + expect(frame['in_app']).to be true + expect(frame['context_line']).not_to be_nil + end end describe '#add_context_lines' do diff --git a/spec/posthog/rails/railtie_spec.rb b/spec/posthog/rails/railtie_spec.rb new file mode 100644 index 0000000..340a344 --- /dev/null +++ b/spec/posthog/rails/railtie_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# Minimal requires for testing the Railtie in isolation +require 'posthog' +require 'rails/railtie' + +# The posthog-rails lib has its own gemspec and isn't in the default load path, +# so we add it manually for testing. +$LOAD_PATH.unshift File.expand_path('../../../posthog-rails/lib', __dir__) + +# Load just enough of posthog-rails to define the Railtie. +# Middleware classes (CaptureExceptions, etc.) are only referenced inside +# initializer blocks, not at file-load time, so we don't need them here. +require 'posthog/rails/configuration' +require 'posthog/rails/railtie' + +RSpec.describe PostHog::Rails::Railtie do + describe 'posthog.insert_middlewares initializer' do + it 'has insert_middleware_after accessible from initializer context' do + # Rails initializer blocks are executed via instance_exec on the Railtie + # instance (see railties/lib/rails/initializable.rb). This means `self` + # inside the block is the Railtie INSTANCE, not the class. + # + # Any method called without an explicit receiver in the block must be + # defined as an instance method (or delegated to one). + railtie = PostHog::Rails::Railtie.instance + expect(railtie).to respond_to(:insert_middleware_after) + end + + it 'successfully calls insert_middleware_after when the initializer runs' do + # Stub the middleware constants referenced in the initializer block + stub_const('ActionDispatch::DebugExceptions', Class.new) + stub_const('ActionDispatch::ShowExceptions', Class.new) + stub_const('PostHog::Rails::RescuedExceptionInterceptor', Class.new) + stub_const('PostHog::Rails::CaptureExceptions', Class.new) + + # Find the initializer by name + initializer = PostHog::Rails::Railtie.initializers.find { |i| i.name == 'posthog.insert_middlewares' } + expect(initializer).not_to be_nil + + # During initialization, app.config.middleware is a MiddlewareStackProxy + # which only supports recording operations — NOT query methods like include?. + # The mock must reflect this accurately. + middleware_proxy = double('MiddlewareStackProxy', insert_after: true) + app = double('app', config: double('config', middleware: middleware_proxy)) + + # Reproduce the exact execution context: the block is run via instance_exec + # on the Railtie instance, with the app passed as the block argument. + # This is how Rails runs initializer blocks internally. + railtie = PostHog::Rails::Railtie.instance + expect do + railtie.instance_exec(app, &initializer.block) + end.not_to raise_error + end + end +end diff --git a/spec/posthog/transport_spec.rb b/spec/posthog/transport_spec.rb index 7dcaa48..7e7b18a 100644 --- a/spec/posthog/transport_spec.rb +++ b/spec/posthog/transport_spec.rb @@ -225,21 +225,21 @@ module PostHog it_behaves_like('non-retried request', 400, '{}') end - context 'request or parsing of response results in an exception' do + context 'response body is malformed JSON' do let(:response_body) { 'Malformed JSON ---' } subject { described_class.new(retries: 0) } - it 'returns a -1 for status' do - expect(subject.send(api_key, batch).status).to eq(-1) + it 'returns the HTTP status code' do + expect(subject.send(api_key, batch).status).to eq(200) end - it 'has a connection error' do + it 'uses the raw body as the error' do error = subject.send(api_key, batch).error - expect(error).to match(/unexpected character.*Malformed/) + expect(error).to eq('Malformed JSON ---') end - it_behaves_like('retried request', 200, 'Malformed JSON ---') + it_behaves_like('non-retried request', 200, 'Malformed JSON ---') end end end