From d725139cbb44e24e2941112ce5a4eaf7f0fe2390 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Thu, 1 Oct 2026 18:51:15 +0200 Subject: [PATCH 01/11] Add failing tests for the status and level of exception responses When a controller raises, no response row is logged, so 5xx and rescued responses like RecordNotFound (404) are never recorded. The error row is always fatal, although the gem silences DebugExceptions, which honours config.action_dispatch.log_rescued_responses (Rails 7.0+) and config.action_dispatch.debug_exception_log_level (Rails 7.1+). The tests render exceptions like a production app and pin a response row with the status the client gets (500, 404 from rescue_responses, also when a template error wraps the exception), a fatal error row by default, no error row for rescued responses when log_rescued_responses is false, and the error row at debug_exception_log_level. The two existing specs that raise now expect the response row too. Co-Authored-By: Claude Opus 5.5 --- .../action_dispatch/debug_exceptions_spec.rb | 3 +- spec/logtail-rails/error_event_spec.rb | 102 +++++++++++++++++- spec/support/rails.rb | 2 + 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/spec/logtail-rails/action_dispatch/debug_exceptions_spec.rb b/spec/logtail-rails/action_dispatch/debug_exceptions_spec.rb index f0e12d8..0bda6ce 100755 --- a/spec/logtail-rails/action_dispatch/debug_exceptions_spec.rb +++ b/spec/logtail-rails/action_dispatch/debug_exceptions_spec.rb @@ -38,10 +38,11 @@ def method_for_action(action_name) suppress(RuntimeError) { dispatch_rails_request("/exception") } lines = clean_lines(io.string.split("\n")) - expect(lines.length).to eq(3) + expect(lines.length).to eq(4) expect(lines[2]).to include('RuntimeError (boom)') expect(lines[2]).to include('fatal') expect(lines[2]).to include("\"error\":{\"name\":\"RuntimeError\",\"message\":\"boom\",\"backtrace_json\":\"[") + expect(lines[3]).to include("Completed 500 Internal Server Error in") end # Remove blank lines since Rails does this to space out requests in the logs diff --git a/spec/logtail-rails/error_event_spec.rb b/spec/logtail-rails/error_event_spec.rb index 08fa0b2..60f4437 100755 --- a/spec/logtail-rails/error_event_spec.rb +++ b/spec/logtail-rails/error_event_spec.rb @@ -47,11 +47,111 @@ def method_for_action(action_name) lines = clean_lines(io.string.split("\n")) - expect(lines.length).to eq(3) + expect(lines.length).to eq(4) expect(lines[0]).to include("Started GET \\\"/rack_error\\\"") expect(lines[1]).to include("Processing by RackErrorController#index as HTML") expect(lines[2]).to include("RuntimeError (Boom!)") + expect(lines[3]).to include("Completed 500 Internal Server Error in") + end + end + + describe "exception responses" do + around(:each) do |example| + class ExceptionResponseController < ActionController::Base + layout nil + + def runtime_error + raise "Boom!" + end + + def record_not_found + raise ActiveRecord::RecordNotFound, "Couldn't find User" + end + + def record_not_found_in_template + render inline: "<% raise ActiveRecord::RecordNotFound, 'Couldn\\'t find User' %>" + end + + def method_for_action(action_name) + action_name + end + end + + ::RailsApp.routes.draw do + get '/runtime_error' => 'exception_response#runtime_error' + get '/record_not_found' => 'exception_response#record_not_found' + get '/record_not_found_in_template' => 'exception_response#record_not_found_in_template' + end + + example.run + + Object.send(:remove_const, :ExceptionResponseController) + end + + it "should log a fatal error and a response with status 500" do + response = dispatch_rendering_exceptions("/runtime_error") + + expect(response.status).to eq(500) + expect(error_rows.map { |row| [row["level"], row["message"]] }).to eq([["fatal", "RuntimeError (Boom!)"]]) + expect(response_statuses).to eq([500]) + expect(response_rows.first["message"]).to match(/\ACompleted 500 Internal Server Error in \d+\.\dms\z/) + end + + it "should log the response with the status from config.action_dispatch.rescue_responses" do + response = dispatch_rendering_exceptions("/record_not_found") + + expect(response.status).to eq(404) + expect(response_statuses).to eq([404]) + expect(response_rows.first["message"]).to match(/\ACompleted 404 Not Found in \d+\.\dms\z/) + expect(error_rows.map { |row| [row["level"], row["message"]] }).to eq([["fatal", "ActiveRecord::RecordNotFound (Couldn't find User)"]]) + end + + it "should log the response with the status of the exception a template error wraps" do + response = dispatch_rendering_exceptions("/record_not_found_in_template") + + expect(response.status).to eq(404) + expect(response_statuses).to eq([404]) + end + + it "should not log rescued responses when config.action_dispatch.log_rescued_responses is false" do + skip("config.action_dispatch.log_rescued_responses is new in Rails 7.0") unless ::Rails.application.env_config.key?("action_dispatch.log_rescued_responses") + + dispatch_rendering_exceptions("/record_not_found", "action_dispatch.log_rescued_responses" => false) + dispatch_rendering_exceptions("/runtime_error", "action_dispatch.log_rescued_responses" => false) + + expect(error_rows.map { |row| row["message"] }).to eq(["RuntimeError (Boom!)"]) + expect(response_statuses).to eq([404, 500]) + end + + it "should log the error at config.action_dispatch.debug_exception_log_level" do + skip("config.action_dispatch.debug_exception_log_level is new in Rails 7.1") unless ::Rails.application.env_config.key?("action_dispatch.debug_exception_log_level") + + dispatch_rendering_exceptions("/runtime_error", "action_dispatch.debug_exception_log_level" => ::Logger::ERROR) + + expect(error_rows.map { |row| [row["level"], row["message"]] }).to eq([["error", "RuntimeError (Boom!)"]]) + end + + # Renders exceptions as error pages like a production app, instead of raising them + def dispatch_rendering_exceptions(path, env = {}) + show_exceptions = ::Rails.gem_version >= Gem::Version.new("7.1") ? :all : true + dispatch_rails_request(path, env.merge("action_dispatch.show_exceptions" => show_exceptions)) + end + + def log_rows + io.string.split("\n").map { |line| JSON.parse(line) } + end + + def error_rows + log_rows.select { |row| row.key?("error") } + end + + def response_rows + log_rows.select { |row| row.dig("event", "http_response_sent") } + end + + def response_statuses + response_rows.map { |row| row["event"]["http_response_sent"]["status"] } end end diff --git a/spec/support/rails.rb b/spec/support/rails.rb index 47cef1c..732257a 100755 --- a/spec/support/rails.rb +++ b/spec/support/rails.rb @@ -15,6 +15,8 @@ class RailsApp < Rails::Application # This ensures our tests fail, otherwise exceptions get swallowed by ActionDispatch::DebugExceptions config.action_dispatch.show_exceptions = false + # What ActiveRecord's railtie adds, which these specs don't load + config.action_dispatch.rescue_responses.merge!("ActiveRecord::RecordNotFound" => :not_found) config.active_support.deprecation = :stderr config.eager_load = false config.hosts = nil From 15f9f96e3b262da664219bb3d7cb61b102b38143 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Thu, 1 Oct 2026 18:52:37 +0200 Subject: [PATCH 02/11] TEMP: run CI against the logtail-rack branch until 0.2.9 is released Points logtail-rack at the claude/response-on-exception branch of logtail-ruby-rack in the root Gemfile and in every gemfiles/*.gemfile, so CI exercises HTTPEvents.status_for_exception. To be replaced by a follow-up commit that requires logtail-rack ~> 0.2, >= 0.2.9 once it's released. Co-Authored-By: Claude Opus 5.5 --- Gemfile | 2 ++ gemfiles/rails-5.0.gemfile | 2 +- gemfiles/rails-5.1.gemfile | 2 +- gemfiles/rails-5.2.gemfile | 2 +- gemfiles/rails-6.0.gemfile | 2 +- gemfiles/rails-6.1.gemfile | 2 +- gemfiles/rails-7.0.gemfile | 2 +- gemfiles/rails-7.1.gemfile | 2 +- gemfiles/rails-7.2.gemfile | 2 +- gemfiles/rails-8.0.gemfile | 2 +- gemfiles/rails-8.1.gemfile | 2 +- gemfiles/rails-edge.gemfile | 2 +- 12 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Gemfile b/Gemfile index 8d20490..faea5b5 100644 --- a/Gemfile +++ b/Gemfile @@ -3,3 +3,5 @@ source "https://rubygems.org" gem 'sidekiq', '>= 7.3.0', require: false, group: :test if RUBY_VERSION >= '2.7.0' gemspec + +gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' diff --git a/gemfiles/rails-5.0.gemfile b/gemfiles/rails-5.0.gemfile index 2da8487..5605d1b 100755 --- a/gemfiles/rails-5.0.gemfile +++ b/gemfiles/rails-5.0.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 5.0.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack' +gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' if RUBY_PLATFORM == "java" gem 'mime-types', '2.6.2' diff --git a/gemfiles/rails-5.1.gemfile b/gemfiles/rails-5.1.gemfile index 6adb3f7..df85dce 100755 --- a/gemfiles/rails-5.1.gemfile +++ b/gemfiles/rails-5.1.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 5.1.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack' +gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' if RUBY_PLATFORM == "java" gem 'mime-types', '2.6.2' diff --git a/gemfiles/rails-5.2.gemfile b/gemfiles/rails-5.2.gemfile index 9375d63..09eebbe 100755 --- a/gemfiles/rails-5.2.gemfile +++ b/gemfiles/rails-5.2.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 5.2.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack' +gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' if RUBY_PLATFORM == "java" gem 'mime-types', '2.6.2' diff --git a/gemfiles/rails-6.0.gemfile b/gemfiles/rails-6.0.gemfile index e6e3320..a1cb2cf 100755 --- a/gemfiles/rails-6.0.gemfile +++ b/gemfiles/rails-6.0.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 6.0.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack' +gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' if RUBY_VERSION >= "3.4.0" gem 'mutex_m' diff --git a/gemfiles/rails-6.1.gemfile b/gemfiles/rails-6.1.gemfile index fb7e121..4b63539 100755 --- a/gemfiles/rails-6.1.gemfile +++ b/gemfiles/rails-6.1.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 6.1.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack' +gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' if RUBY_VERSION >= "3.4.0" gem 'mutex_m' diff --git a/gemfiles/rails-7.0.gemfile b/gemfiles/rails-7.0.gemfile index ad045e1..939bb61 100755 --- a/gemfiles/rails-7.0.gemfile +++ b/gemfiles/rails-7.0.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 7.0.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack' +gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' if RUBY_VERSION >= "3.4.0" gem 'mutex_m' diff --git a/gemfiles/rails-7.1.gemfile b/gemfiles/rails-7.1.gemfile index 83ac20c..3198b35 100755 --- a/gemfiles/rails-7.1.gemfile +++ b/gemfiles/rails-7.1.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 7.1.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack' +gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' # ActiveSupport passes quirks_mode to JSON.generate until 7.2.4 and 8.1, json 3 no longer accepts it gem 'json', '< 3' diff --git a/gemfiles/rails-7.2.gemfile b/gemfiles/rails-7.2.gemfile index 2a86de7..9475930 100644 --- a/gemfiles/rails-7.2.gemfile +++ b/gemfiles/rails-7.2.gemfile @@ -6,6 +6,6 @@ gem 'rails', '~> 7.2.4' gem 'sidekiq', '>= 7.3.0', require: false gem 'logtail' -gem 'logtail-rack' +gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' gemspec :path => '../' diff --git a/gemfiles/rails-8.0.gemfile b/gemfiles/rails-8.0.gemfile index d34482f..1bf9a1a 100755 --- a/gemfiles/rails-8.0.gemfile +++ b/gemfiles/rails-8.0.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 8.0.0' gem 'sidekiq', '>= 7.3.0', require: false gem 'logtail' -gem 'logtail-rack' +gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' # ActiveSupport passes quirks_mode to JSON.generate until 7.2.4 and 8.1, json 3 no longer accepts it gem 'json', '< 3' diff --git a/gemfiles/rails-8.1.gemfile b/gemfiles/rails-8.1.gemfile index 03b4e39..2c2705c 100755 --- a/gemfiles/rails-8.1.gemfile +++ b/gemfiles/rails-8.1.gemfile @@ -4,6 +4,6 @@ gem 'rails', '~> 8.1.0' gem 'sidekiq', '>= 7.3.0', require: false gem 'logtail' -gem 'logtail-rack' +gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' gemspec :path => '../' diff --git a/gemfiles/rails-edge.gemfile b/gemfiles/rails-edge.gemfile index ac2df52..85c97d8 100755 --- a/gemfiles/rails-edge.gemfile +++ b/gemfiles/rails-edge.gemfile @@ -4,7 +4,7 @@ gem 'rails', github: 'rails/rails', branch: 'main' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack' +gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' if RUBY_PLATFORM == "java" gem 'mime-types', '2.6.2' From 70159336f6843b44406b60754628bafd0e8bf2a2 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Thu, 1 Oct 2026 18:55:59 +0200 Subject: [PATCH 03/11] Log the real status of exception responses, honour Rails' exception log settings The integration sets HTTPEvents.status_for_exception to ActionDispatch::ExceptionWrapper.new(nil, exception).status_code, the status ShowExceptions renders: config.action_dispatch.rescue_responses (404 for ActiveRecord::RecordNotFound), with ActionView::Template::Error unwrapped the way each Rails version does it, 500 otherwise. ErrorEvent now reads the request env keys DebugExceptions reads, whose logging the gem silences: no error row for rescued responses when action_dispatch.log_rescued_responses is false (Rails 7.0+), and the row at action_dispatch.debug_exception_log_level (Rails 7.1+). Without them, every exception is logged at fatal as before. The settings specs now change Rails.application.env_config, as Rails merges it into every request env and overrides per-request values. Co-Authored-By: Claude Opus 5.5 --- lib/logtail-rails.rb | 6 ++++++ lib/logtail-rails/error_event.rb | 29 +++++++++++++++++++------- spec/logtail-rails/error_event_spec.rb | 24 +++++++++++++++++++-- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/lib/logtail-rails.rb b/lib/logtail-rails.rb index 6d431bf..3d078d8 100755 --- a/lib/logtail-rails.rb +++ b/lib/logtail-rails.rb @@ -39,6 +39,12 @@ def self.enabled? def self.integrate! return false if !enabled? + # Log the status Rails responds with when the app raises, the way ShowExceptions + # determines it: from config.action_dispatch.rescue_responses, 500 by default. + Logtail::Integrations::Rack::HTTPEvents.status_for_exception = lambda do |exception| + ::ActionDispatch::ExceptionWrapper.new(nil, exception).status_code + end + ActionController.integrate! ActionDispatch.integrate! ActionView.integrate! diff --git a/lib/logtail-rails/error_event.rb b/lib/logtail-rails/error_event.rb index 21e53fb..2a372c4 100755 --- a/lib/logtail-rails/error_event.rb +++ b/lib/logtail-rails/error_event.rb @@ -18,18 +18,25 @@ class ErrorEvent < Logtail::Integrations::Rack::Middleware # We determine this when the app loads to avoid the overhead on a per request basis. EXCEPTION_WRAPPER_TAKES_CLEANER = defined?(::ActionDispatch::ExceptionWrapper) && !::ActionDispatch::ExceptionWrapper.instance_methods.include?(:env) + # config.action_dispatch.log_rescued_responses (Rails 7.0+) and + # config.action_dispatch.debug_exception_log_level (Rails 7.1+), as ActionDispatch::DebugExceptions + # reads them. This gem silences its logging and logs the exception here instead. + LOG_RESCUED_RESPONSES_KEY = "action_dispatch.log_rescued_responses".freeze + DEBUG_EXCEPTION_LOG_LEVEL_KEY = "action_dispatch.debug_exception_log_level".freeze def call(env) begin status, headers, body = @app.call(env) rescue Exception => exception - Config.instance.logger.fatal do - backtrace = extract_backtrace(env, exception) - Events::Error.new( - name: exception.class.name, - error_message: exception.message, - backtrace: backtrace - ) + if log_exception?(env, exception) + Config.instance.logger.add(env[DEBUG_EXCEPTION_LOG_LEVEL_KEY] || ::Logger::FATAL) do + backtrace = extract_backtrace(env, exception) + Events::Error.new( + name: exception.class.name, + error_message: exception.message, + backtrace: backtrace + ) + end end raise exception @@ -38,6 +45,14 @@ def call(env) private + # Like DebugExceptions, skip rescued responses such as ActiveRecord::RecordNotFound when + # log_rescued_responses is false. Older Rails versions don't set it and log every exception. + def log_exception?(env, exception) + return true if !env.key?(LOG_RESCUED_RESPONSES_KEY) || env[LOG_RESCUED_RESPONSES_KEY] + + !::ActionDispatch::ExceptionWrapper.rescue_responses.key?(exception.class.name) + end + # Rails provides a backtrace cleaner, so we use it here. def extract_backtrace(env, exception) if defined?(::ActionDispatch::ExceptionWrapper) diff --git a/spec/logtail-rails/error_event_spec.rb b/spec/logtail-rails/error_event_spec.rb index 60f4437..5572dc7 100755 --- a/spec/logtail-rails/error_event_spec.rb +++ b/spec/logtail-rails/error_event_spec.rb @@ -133,9 +133,29 @@ def method_for_action(action_name) end # Renders exceptions as error pages like a production app, instead of raising them - def dispatch_rendering_exceptions(path, env = {}) + def dispatch_rendering_exceptions(path, env_config = {}) show_exceptions = ::Rails.gem_version >= Gem::Version.new("7.1") ? :all : true - dispatch_rails_request(path, env.merge("action_dispatch.show_exceptions" => show_exceptions)) + + with_env_config(env_config.merge("action_dispatch.show_exceptions" => show_exceptions)) do + dispatch_rails_request(path) + end + end + + # Rails copies its config.action_dispatch settings from env_config into every request env + def with_env_config(settings) + env_config = ::Rails.application.env_config + previous = settings.keys.map { |key| [key, env_config.key?(key), env_config[key]] } + env_config.merge!(settings) + + yield + ensure + previous.each do |key, existed, value| + if existed + env_config[key] = value + else + env_config.delete(key) + end + end end def log_rows From b604f465cb6406b6c0d562504ea0cc1968280fe4 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Thu, 1 Oct 2026 18:37:15 +0200 Subject: [PATCH 04/11] Expect real response durations in the request specs logtail-rack 0.2.8 times responses with the monotonic clock, which Timecop doesn't freeze, so "Completed 200 OK in 0.0ms" no longer holds. The last CI run on main still resolved logtail-rack 0.2.7. Co-Authored-By: Claude Opus 5.5 --- spec/logtail-rails/http_events_spec.rb | 4 ++-- spec/logtail-rails/rack_logger_spec.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/logtail-rails/http_events_spec.rb b/spec/logtail-rails/http_events_spec.rb index 2e8bb2a..d185ab9 100755 --- a/spec/logtail-rails/http_events_spec.rb +++ b/spec/logtail-rails/http_events_spec.rb @@ -45,7 +45,7 @@ def method_for_action(action_name) expect(lines[0]).to include("Started GET \\\"/rack_http\\\"") expect(lines[1]).to include("Processing by RackHttpController#index as HTML") - expect(lines[2]).to include("Completed 200 OK in 0.0ms") + expect(lines[2]).to match(/Completed 200 OK in \d+\.\d+ms/) end context "with the route silenced" do @@ -87,7 +87,7 @@ def method_for_action(action_name) expect(lines.length).to eq(2) expect(lines[0]).to include("Processing by RackHttpController#index as HTML") - expect(lines[1]).to include("GET /rack_http completed with 200 OK in 0.0ms") + expect(lines[1]).to match(/GET \/rack_http completed with 200 OK in \d+\.\d+ms/) end end end diff --git a/spec/logtail-rails/rack_logger_spec.rb b/spec/logtail-rails/rack_logger_spec.rb index e3c2109..6976159 100755 --- a/spec/logtail-rails/rack_logger_spec.rb +++ b/spec/logtail-rails/rack_logger_spec.rb @@ -49,7 +49,7 @@ def method_for_action(action_name) expect(lines.length).to eq(3) expect(lines[0]).to include("Started GET \\\"/rails_rack_logger\\\"") expect(lines[1]).to include("Processing by RailsRackLoggerController#index as HTML") - expect(lines[2]).to include("Completed 200 OK in 0.0ms") + expect(lines[2]).to match(/Completed 200 OK in \d+\.\d+ms/) ensure Logtail::Integrations::Rails::EventLogSubscriber.enabled = original_enabled end From 36566aa9f16bf8168ef0dbd6247efd98078baa41 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Thu, 1 Oct 2026 19:22:49 +0200 Subject: [PATCH 05/11] Never let logging the error event replace the app's exception ErrorEvent logged inside its rescue without a guard, so an error while building or writing the error event replaced the exception of the app. A StandardError there now goes to Logtail::Config.instance.debug, and the original exception is always re-raised. Kept local to ErrorEvent, so this doesn't depend on logtail-rack's log_safely. Both tests failed before the guard: the IOError of the logger and the ArgumentError of the event builder propagated instead of the app's error. Co-Authored-By: Claude Opus 5.5 --- lib/logtail-rails/error_event.rb | 27 ++++++++++++++++---------- spec/logtail-rails/error_event_spec.rb | 15 ++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/lib/logtail-rails/error_event.rb b/lib/logtail-rails/error_event.rb index 2a372c4..7f37df7 100755 --- a/lib/logtail-rails/error_event.rb +++ b/lib/logtail-rails/error_event.rb @@ -28,16 +28,7 @@ def call(env) begin status, headers, body = @app.call(env) rescue Exception => exception - if log_exception?(env, exception) - Config.instance.logger.add(env[DEBUG_EXCEPTION_LOG_LEVEL_KEY] || ::Logger::FATAL) do - backtrace = extract_backtrace(env, exception) - Events::Error.new( - name: exception.class.name, - error_message: exception.message, - backtrace: backtrace - ) - end - end + log_exception(env, exception) raise exception end @@ -45,6 +36,22 @@ def call(env) private + # Never raises, so that the exception of the app is the one that propagates. + def log_exception(env, exception) + return if !log_exception?(env, exception) + + Config.instance.logger.add(env[DEBUG_EXCEPTION_LOG_LEVEL_KEY] || ::Logger::FATAL) do + backtrace = extract_backtrace(env, exception) + Events::Error.new( + name: exception.class.name, + error_message: exception.message, + backtrace: backtrace + ) + end + rescue StandardError => e + Config.instance.debug { "#{self.class.name} could not log #{exception.class}: #{e.inspect}" } + end + # Like DebugExceptions, skip rescued responses such as ActiveRecord::RecordNotFound when # log_rescued_responses is false. Older Rails versions don't set it and log every exception. def log_exception?(env, exception) diff --git a/spec/logtail-rails/error_event_spec.rb b/spec/logtail-rails/error_event_spec.rb index 5572dc7..f2f5d48 100755 --- a/spec/logtail-rails/error_event_spec.rb +++ b/spec/logtail-rails/error_event_spec.rb @@ -54,6 +54,21 @@ def method_for_action(action_name) expect(lines[2]).to include("RuntimeError (Boom!)") expect(lines[3]).to include("Completed 500 Internal Server Error in") end + + it "should re-raise the exception of the app when the logger fails" do + error = RuntimeError.new("Boom!") + allow(logger).to receive(:add).and_raise(IOError, "closed stream") + + expect { described_class.new(->(_env) { raise error }).call(Rack::MockRequest.env_for("/")) }.to raise_error(RuntimeError) { |raised| expect(raised).to be(error) } + end + + it "should re-raise the exception of the app when building the error event fails" do + error = RuntimeError.new("Boom!") + allow(Logtail::Events::Error).to receive(:new).and_raise(ArgumentError, "bad event") + + expect { described_class.new(->(_env) { raise error }).call(Rack::MockRequest.env_for("/")) }.to raise_error(RuntimeError) { |raised| expect(raised).to be(error) } + expect(io.string).to eq("") + end end describe "exception responses" do From 7302439f3aabd6a6227e958572dbef97b9a86da9 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Thu, 1 Oct 2026 19:30:46 +0200 Subject: [PATCH 06/11] Expect the error row to stay fatal whatever debug_exception_log_level says config.load_defaults 7.1 sets config.action_dispatch.debug_exception_log_level to :error, so following it would turn every exception row of most modern apps, real 500s included, from fatal into error and break alerts on level = fatal. That is too big a change for a patch release, so the error row keeps its fatal level, as on main. Co-Authored-By: Claude Opus 5.5 --- spec/logtail-rails/error_event_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/logtail-rails/error_event_spec.rb b/spec/logtail-rails/error_event_spec.rb index f2f5d48..af9cfe1 100755 --- a/spec/logtail-rails/error_event_spec.rb +++ b/spec/logtail-rails/error_event_spec.rb @@ -139,12 +139,12 @@ def method_for_action(action_name) expect(response_statuses).to eq([404, 500]) end - it "should log the error at config.action_dispatch.debug_exception_log_level" do + it "should log the error at fatal even when config.action_dispatch.debug_exception_log_level is :error" do skip("config.action_dispatch.debug_exception_log_level is new in Rails 7.1") unless ::Rails.application.env_config.key?("action_dispatch.debug_exception_log_level") dispatch_rendering_exceptions("/runtime_error", "action_dispatch.debug_exception_log_level" => ::Logger::ERROR) - expect(error_rows.map { |row| [row["level"], row["message"]] }).to eq([["error", "RuntimeError (Boom!)"]]) + expect(error_rows.map { |row| [row["level"], row["message"]] }).to eq([["fatal", "RuntimeError (Boom!)"]]) end # Renders exceptions as error pages like a production app, instead of raising them From 6d8e4c9663bb15429f3a50f884423b2a2bc626e6 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Thu, 1 Oct 2026 19:31:24 +0200 Subject: [PATCH 07/11] Log the error row at fatal again, leave debug_exception_log_level out ErrorEvent stops reading action_dispatch.debug_exception_log_level and logs at fatal exactly as on main, since load_defaults 7.1 sets it to :error for most apps. Following it may come in a minor release. Honouring log_rescued_responses, the status resolver and the logging guard stay. Co-Authored-By: Claude Opus 5.5 --- lib/logtail-rails/error_event.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/logtail-rails/error_event.rb b/lib/logtail-rails/error_event.rb index 7f37df7..3755ee2 100755 --- a/lib/logtail-rails/error_event.rb +++ b/lib/logtail-rails/error_event.rb @@ -18,11 +18,9 @@ class ErrorEvent < Logtail::Integrations::Rack::Middleware # We determine this when the app loads to avoid the overhead on a per request basis. EXCEPTION_WRAPPER_TAKES_CLEANER = defined?(::ActionDispatch::ExceptionWrapper) && !::ActionDispatch::ExceptionWrapper.instance_methods.include?(:env) - # config.action_dispatch.log_rescued_responses (Rails 7.0+) and - # config.action_dispatch.debug_exception_log_level (Rails 7.1+), as ActionDispatch::DebugExceptions - # reads them. This gem silences its logging and logs the exception here instead. + # config.action_dispatch.log_rescued_responses (Rails 7.0+), as ActionDispatch::DebugExceptions + # reads it. This gem silences its logging and logs the exception here instead. LOG_RESCUED_RESPONSES_KEY = "action_dispatch.log_rescued_responses".freeze - DEBUG_EXCEPTION_LOG_LEVEL_KEY = "action_dispatch.debug_exception_log_level".freeze def call(env) begin @@ -40,7 +38,7 @@ def call(env) def log_exception(env, exception) return if !log_exception?(env, exception) - Config.instance.logger.add(env[DEBUG_EXCEPTION_LOG_LEVEL_KEY] || ::Logger::FATAL) do + Config.instance.logger.fatal do backtrace = extract_backtrace(env, exception) Events::Error.new( name: exception.class.name, From 1954353ae955d331a8fd23c5a3b83cbe8e69d54c Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Fri, 2 Oct 2026 11:09:10 +0200 Subject: [PATCH 08/11] Add failing tests for logging exceptions at debug_exception_log_level ErrorEvent has logged every exception at fatal since the gem's first version. Rails 7.1 added config.action_dispatch.debug_exception_log_level, the level DebugExceptions logs exceptions at (railtie default :fatal, config.load_defaults 7.1 and later set :error), but the gem silences DebugExceptions' logging, so the setting never reached the exception rows. The test that pinned the row at fatal under :error now expects error, and new tests pin: :warn logs the RuntimeError and the rescued RecordNotFound at warn, an app that doesn't set it keeps Rails' :fatal, and on Rails before 7.1, which has no such setting, every exception stays fatal. Co-Authored-By: Claude Opus 5.5 --- spec/logtail-rails/error_event_spec.rb | 33 +++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/spec/logtail-rails/error_event_spec.rb b/spec/logtail-rails/error_event_spec.rb index af9cfe1..cc58666 100755 --- a/spec/logtail-rails/error_event_spec.rb +++ b/spec/logtail-rails/error_event_spec.rb @@ -139,14 +139,45 @@ def method_for_action(action_name) expect(response_statuses).to eq([404, 500]) end - it "should log the error at fatal even when config.action_dispatch.debug_exception_log_level is :error" do + it "should log the error at config.action_dispatch.debug_exception_log_level, :error with load_defaults 7.1 or later" do skip("config.action_dispatch.debug_exception_log_level is new in Rails 7.1") unless ::Rails.application.env_config.key?("action_dispatch.debug_exception_log_level") dispatch_rendering_exceptions("/runtime_error", "action_dispatch.debug_exception_log_level" => ::Logger::ERROR) + expect(error_rows.map { |row| [row["level"], row["message"]] }).to eq([["error", "RuntimeError (Boom!)"]]) + expect(response_statuses).to eq([500]) + end + + it "should log every error, rescued responses too, at warn when config.action_dispatch.debug_exception_log_level is :warn" do + skip("config.action_dispatch.debug_exception_log_level is new in Rails 7.1") unless ::Rails.application.env_config.key?("action_dispatch.debug_exception_log_level") + + dispatch_rendering_exceptions("/runtime_error", "action_dispatch.debug_exception_log_level" => ::Logger::WARN) + dispatch_rendering_exceptions("/record_not_found", "action_dispatch.debug_exception_log_level" => ::Logger::WARN) + + expect(error_rows.map { |row| [row["level"], row["message"]] }).to eq([["warn", "RuntimeError (Boom!)"], ["warn", "ActiveRecord::RecordNotFound (Couldn't find User)"]]) + expect(response_statuses).to eq([500, 404]) + end + + it "should log the error at fatal, Rails' default, when the app doesn't set config.action_dispatch.debug_exception_log_level" do + skip("config.action_dispatch.debug_exception_log_level is new in Rails 7.1") unless ::Rails.application.env_config.key?("action_dispatch.debug_exception_log_level") + + # The spec app neither sets it nor loads the 7.1 defaults, so it has the railtie's :fatal + expect(::Rails.application.env_config["action_dispatch.debug_exception_log_level"]).to eq(::Logger::FATAL) + + dispatch_rendering_exceptions("/runtime_error") + expect(error_rows.map { |row| [row["level"], row["message"]] }).to eq([["fatal", "RuntimeError (Boom!)"]]) end + it "should log every error at fatal on Rails versions without config.action_dispatch.debug_exception_log_level" do + skip("config.action_dispatch.debug_exception_log_level exists since Rails 7.1") if ::Rails.application.env_config.key?("action_dispatch.debug_exception_log_level") + + dispatch_rendering_exceptions("/runtime_error") + dispatch_rendering_exceptions("/record_not_found") + + expect(error_rows.map { |row| [row["level"], row["message"]] }).to eq([["fatal", "RuntimeError (Boom!)"], ["fatal", "ActiveRecord::RecordNotFound (Couldn't find User)"]]) + end + # Renders exceptions as error pages like a production app, instead of raising them def dispatch_rendering_exceptions(path, env_config = {}) show_exceptions = ::Rails.gem_version >= Gem::Version.new("7.1") ? :all : true From 428bed9520b67608ab3ecea668ef28f71f16feec Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Fri, 2 Oct 2026 11:11:25 +0200 Subject: [PATCH 09/11] Log exceptions at Rails' debug_exception_log_level ErrorEvent reads the request env key action_dispatch.debug_exception_log_level the way DebugExceptions does (Rails copies the configured level into every request env as a Logger severity) and logs the exception row at that level. Without the key, on Rails before 7.1, the row stays fatal. This brings back the code that 6d8e4c9 took out of the 0.2.15 patch. Apps on config.load_defaults 7.1 or later now get their exception rows at error instead of fatal, so alerts and saved searches on level = fatal need adjusting. Setting debug_exception_log_level to :fatal keeps the old level. Co-Authored-By: Claude Opus 5.5 --- lib/logtail-rails/error_event.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/logtail-rails/error_event.rb b/lib/logtail-rails/error_event.rb index 3755ee2..7f37df7 100755 --- a/lib/logtail-rails/error_event.rb +++ b/lib/logtail-rails/error_event.rb @@ -18,9 +18,11 @@ class ErrorEvent < Logtail::Integrations::Rack::Middleware # We determine this when the app loads to avoid the overhead on a per request basis. EXCEPTION_WRAPPER_TAKES_CLEANER = defined?(::ActionDispatch::ExceptionWrapper) && !::ActionDispatch::ExceptionWrapper.instance_methods.include?(:env) - # config.action_dispatch.log_rescued_responses (Rails 7.0+), as ActionDispatch::DebugExceptions - # reads it. This gem silences its logging and logs the exception here instead. + # config.action_dispatch.log_rescued_responses (Rails 7.0+) and + # config.action_dispatch.debug_exception_log_level (Rails 7.1+), as ActionDispatch::DebugExceptions + # reads them. This gem silences its logging and logs the exception here instead. LOG_RESCUED_RESPONSES_KEY = "action_dispatch.log_rescued_responses".freeze + DEBUG_EXCEPTION_LOG_LEVEL_KEY = "action_dispatch.debug_exception_log_level".freeze def call(env) begin @@ -38,7 +40,7 @@ def call(env) def log_exception(env, exception) return if !log_exception?(env, exception) - Config.instance.logger.fatal do + Config.instance.logger.add(env[DEBUG_EXCEPTION_LOG_LEVEL_KEY] || ::Logger::FATAL) do backtrace = extract_backtrace(env, exception) Events::Error.new( name: exception.class.name, From f6018b1e6494ce26c065fe3ceb5c0274c1e801d1 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Fri, 2 Oct 2026 14:03:48 +0200 Subject: [PATCH 10/11] Revert "TEMP: run CI against the logtail-rack branch until 0.2.9 is released" This reverts commit 15f9f96e3b262da664219bb3d7cb61b102b38143. --- Gemfile | 2 -- gemfiles/rails-5.0.gemfile | 2 +- gemfiles/rails-5.1.gemfile | 2 +- gemfiles/rails-5.2.gemfile | 2 +- gemfiles/rails-6.0.gemfile | 2 +- gemfiles/rails-6.1.gemfile | 2 +- gemfiles/rails-7.0.gemfile | 2 +- gemfiles/rails-7.1.gemfile | 2 +- gemfiles/rails-7.2.gemfile | 2 +- gemfiles/rails-8.0.gemfile | 2 +- gemfiles/rails-8.1.gemfile | 2 +- gemfiles/rails-edge.gemfile | 2 +- 12 files changed, 11 insertions(+), 13 deletions(-) diff --git a/Gemfile b/Gemfile index faea5b5..8d20490 100644 --- a/Gemfile +++ b/Gemfile @@ -3,5 +3,3 @@ source "https://rubygems.org" gem 'sidekiq', '>= 7.3.0', require: false, group: :test if RUBY_VERSION >= '2.7.0' gemspec - -gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' diff --git a/gemfiles/rails-5.0.gemfile b/gemfiles/rails-5.0.gemfile index 5605d1b..2da8487 100755 --- a/gemfiles/rails-5.0.gemfile +++ b/gemfiles/rails-5.0.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 5.0.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' +gem 'logtail-rack' if RUBY_PLATFORM == "java" gem 'mime-types', '2.6.2' diff --git a/gemfiles/rails-5.1.gemfile b/gemfiles/rails-5.1.gemfile index df85dce..6adb3f7 100755 --- a/gemfiles/rails-5.1.gemfile +++ b/gemfiles/rails-5.1.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 5.1.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' +gem 'logtail-rack' if RUBY_PLATFORM == "java" gem 'mime-types', '2.6.2' diff --git a/gemfiles/rails-5.2.gemfile b/gemfiles/rails-5.2.gemfile index 09eebbe..9375d63 100755 --- a/gemfiles/rails-5.2.gemfile +++ b/gemfiles/rails-5.2.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 5.2.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' +gem 'logtail-rack' if RUBY_PLATFORM == "java" gem 'mime-types', '2.6.2' diff --git a/gemfiles/rails-6.0.gemfile b/gemfiles/rails-6.0.gemfile index a1cb2cf..e6e3320 100755 --- a/gemfiles/rails-6.0.gemfile +++ b/gemfiles/rails-6.0.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 6.0.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' +gem 'logtail-rack' if RUBY_VERSION >= "3.4.0" gem 'mutex_m' diff --git a/gemfiles/rails-6.1.gemfile b/gemfiles/rails-6.1.gemfile index 4b63539..fb7e121 100755 --- a/gemfiles/rails-6.1.gemfile +++ b/gemfiles/rails-6.1.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 6.1.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' +gem 'logtail-rack' if RUBY_VERSION >= "3.4.0" gem 'mutex_m' diff --git a/gemfiles/rails-7.0.gemfile b/gemfiles/rails-7.0.gemfile index 939bb61..ad045e1 100755 --- a/gemfiles/rails-7.0.gemfile +++ b/gemfiles/rails-7.0.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 7.0.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' +gem 'logtail-rack' if RUBY_VERSION >= "3.4.0" gem 'mutex_m' diff --git a/gemfiles/rails-7.1.gemfile b/gemfiles/rails-7.1.gemfile index 3198b35..83ac20c 100755 --- a/gemfiles/rails-7.1.gemfile +++ b/gemfiles/rails-7.1.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 7.1.0' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' +gem 'logtail-rack' # ActiveSupport passes quirks_mode to JSON.generate until 7.2.4 and 8.1, json 3 no longer accepts it gem 'json', '< 3' diff --git a/gemfiles/rails-7.2.gemfile b/gemfiles/rails-7.2.gemfile index 9475930..2a86de7 100644 --- a/gemfiles/rails-7.2.gemfile +++ b/gemfiles/rails-7.2.gemfile @@ -6,6 +6,6 @@ gem 'rails', '~> 7.2.4' gem 'sidekiq', '>= 7.3.0', require: false gem 'logtail' -gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' +gem 'logtail-rack' gemspec :path => '../' diff --git a/gemfiles/rails-8.0.gemfile b/gemfiles/rails-8.0.gemfile index 1bf9a1a..d34482f 100755 --- a/gemfiles/rails-8.0.gemfile +++ b/gemfiles/rails-8.0.gemfile @@ -4,7 +4,7 @@ gem 'rails', '~> 8.0.0' gem 'sidekiq', '>= 7.3.0', require: false gem 'logtail' -gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' +gem 'logtail-rack' # ActiveSupport passes quirks_mode to JSON.generate until 7.2.4 and 8.1, json 3 no longer accepts it gem 'json', '< 3' diff --git a/gemfiles/rails-8.1.gemfile b/gemfiles/rails-8.1.gemfile index 2c2705c..03b4e39 100755 --- a/gemfiles/rails-8.1.gemfile +++ b/gemfiles/rails-8.1.gemfile @@ -4,6 +4,6 @@ gem 'rails', '~> 8.1.0' gem 'sidekiq', '>= 7.3.0', require: false gem 'logtail' -gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' +gem 'logtail-rack' gemspec :path => '../' diff --git a/gemfiles/rails-edge.gemfile b/gemfiles/rails-edge.gemfile index 85c97d8..ac2df52 100755 --- a/gemfiles/rails-edge.gemfile +++ b/gemfiles/rails-edge.gemfile @@ -4,7 +4,7 @@ gem 'rails', github: 'rails/rails', branch: 'main' gem 'sidekiq', '>= 7.3.0', require: false if RUBY_VERSION >= '2.7.0' gem 'logtail' -gem 'logtail-rack', git: 'https://github.com/logtail/logtail-ruby-rack.git', branch: 'claude/response-on-exception' +gem 'logtail-rack' if RUBY_PLATFORM == "java" gem 'mime-types', '2.6.2' From 1db9eaa622ee51c51a5328815de808ca54fd7d19 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Fri, 2 Oct 2026 14:03:54 +0200 Subject: [PATCH 11/11] Require logtail 0.1.21 and logtail-rack 0.2.9 Co-Authored-By: Claude Opus 5.5 --- logtail-rails.gemspec | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/logtail-rails.gemspec b/logtail-rails.gemspec index f3faf7f..a4f5239 100644 --- a/logtail-rails.gemspec +++ b/logtail-rails.gemspec @@ -27,8 +27,8 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_runtime_dependency "logtail", "~> 0.1", ">= 0.1.14" - spec.add_runtime_dependency "logtail-rack", "~> 0.1" + spec.add_runtime_dependency "logtail", "~> 0.1", ">= 0.1.21" + spec.add_runtime_dependency "logtail-rack", "~> 0.2", ">= 0.2.9" spec.add_runtime_dependency 'activerecord', '>= 5.0.0' spec.add_runtime_dependency 'railties', '>= 5.0.0'