diff --git a/actioncable/CHANGELOG.md b/actioncable/CHANGELOG.md index b020868a759577501ff22ea48b49087ec5250286..c504e7058a28b0f0bcab93450f756a45c34e9e68 100644 --- a/actioncable/CHANGELOG.md +++ b/actioncable/CHANGELOG.md @@ -1,3 +1,9 @@ +* `ActionCable::Connection::Base` now allows intercepting unhandled exceptions + with `rescue_from` before they are logged, which is useful for error reporting + tools and other integrations. + + *Justin Talbott* + * Add `ActionCable::Channel#stream_or_reject_for` to stream if record is present, otherwise reject the connection *Atul Bhosale* diff --git a/actioncable/lib/action_cable/connection/base.rb b/actioncable/lib/action_cable/connection/base.rb index c469f7066cfc344e2fe4013e5e6f60bad3baab50..a243a91def60b3dc7798304f30d14a1bc12f37a0 100644 --- a/actioncable/lib/action_cable/connection/base.rb +++ b/actioncable/lib/action_cable/connection/base.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "action_dispatch" +require "active_support/rescuable" module ActionCable module Connection @@ -46,6 +47,7 @@ class Base include Identification include InternalChannel include Authorization + include ActiveSupport::Rescuable attr_reader :server, :env, :subscriptions, :logger, :worker_pool, :protocol delegate :event_loop, :pubsub, to: :server diff --git a/actioncable/lib/action_cable/connection/subscriptions.rb b/actioncable/lib/action_cable/connection/subscriptions.rb index 1ad8d051077efb4a4837a4b9d133a2e745995918..41b4cc4e922d94c72ad3509b8b0f6e5aa90b6334 100644 --- a/actioncable/lib/action_cable/connection/subscriptions.rb +++ b/actioncable/lib/action_cable/connection/subscriptions.rb @@ -21,6 +21,7 @@ def execute_command(data) logger.error "Received unrecognized command in #{data.inspect}" end rescue Exception => e + @connection.rescue_with_handler(e) logger.error "Could not execute command from (#{data.inspect}) [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}" end diff --git a/actioncable/test/connection/subscriptions_test.rb b/actioncable/test/connection/subscriptions_test.rb index 902085c5d6010bbd4b0ae18b8e3e856a59f2d590..1da2f781792edafedd3db7775244b0273bc25e0a 100644 --- a/actioncable/test/connection/subscriptions_test.rb +++ b/actioncable/test/connection/subscriptions_test.rb @@ -3,12 +3,25 @@ require "test_helper" class ActionCable::Connection::SubscriptionsTest < ActionCable::TestCase + class ChatChannelError < Exception; end + class Connection < ActionCable::Connection::Base - attr_reader :websocket + attr_reader :websocket, :exceptions + + rescue_from ChatChannelError, with: :error_handler + + def initialize(*) + super + @exceptions = [] + end def send_async(method, *args) send method, *args end + + def error_handler(e) + @exceptions << e + end end class ChatChannel < ActionCable::Channel::Base @@ -22,6 +35,10 @@ def subscribed def speak(data) @lines << data end + + def throw_exception(_data) + raise ChatChannelError.new("Uh Oh") + end end setup do @@ -85,6 +102,19 @@ def speak(data) end end + test "accessing exceptions thrown during command execution" do + run_in_eventmachine do + setup_connection + subscribe_to_chat_channel + + data = { "content" => "Hello World!", "action" => "throw_exception" } + @subscriptions.execute_command "command" => "message", "identifier" => @chat_identifier, "data" => ActiveSupport::JSON.encode(data) + + exception = @connection.exceptions.first + assert_kind_of ChatChannelError, exception + end + end + test "unsubscribe from all" do run_in_eventmachine do setup_connection diff --git a/guides/source/action_cable_overview.md b/guides/source/action_cable_overview.md index 23a43e4cc098907f1ad16fee11f301604a7a7966..5ebbf1e7e0415d955100c3a1085a228c0fe8a65a 100644 --- a/guides/source/action_cable_overview.md +++ b/guides/source/action_cable_overview.md @@ -128,6 +128,27 @@ can use this approach: verified_user = User.find_by(id: cookies.encrypted['_session']['user_id']) ``` +#### Exception Handling + +By default, unhandled exceptions are caught and logged to Rails' logger. If you would like to +globally intercept these exceptions and report them to an external bug tracking service, for +example, you can do so with `rescue_with`. + +```ruby +# app/channels/application_cable/connection.rb +module ApplicationCable + class Connection < ActionCable::Connection::Base + rescue_from StandardError, with: :report_error + + private + + def report_error(e) + SomeExternalBugtrackingService.notify(e) + end + end +end +``` + ### Channels A *channel* encapsulates a logical unit of work, similar to what a controller does in a @@ -175,6 +196,25 @@ class ChatChannel < ApplicationCable::Channel end ``` +#### Exception Handling + +As with `ActionCable::Connection::Base`, you can also use +[`rescue_with`](https://api.rubyonrails.org/classes/ActiveSupport/Rescuable/ClassMethods.html) +on a specific channel to handle raised exceptions: + +```ruby +# app/channels/chat_channel.rb +class ChatChannel < ApplicationCable::Channel + rescue_from 'MyError', with: :deliver_error_message + + private + + def deliver_error_message(e) + broadcast_to(...) + end +end +``` + ## Client-Side Components ### Connections