Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion lib/posthog/exception_capture.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions lib/posthog/send_worker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion lib/posthog/transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/posthog/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module PostHog
VERSION = '3.5.2'
VERSION = '3.5.3'
end
2 changes: 1 addition & 1 deletion posthog-rails/lib/posthog/rails/capture_exceptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
12 changes: 5 additions & 7 deletions posthog-rails/lib/posthog/rails/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a problem here, though. If target doesn't exist then we won't insert our middleware, and that's a bad failure. I wonder if there's a better way. Will keep it like this for now, but I'll keep thinking about it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed - i opened #99 and we can think through a more robust approach

# 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
Expand Down
22 changes: 22 additions & 0 deletions spec/posthog/exception_capture_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions spec/posthog/rails/railtie_spec.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 6 additions & 6 deletions spec/posthog/transport_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading