Do not hide your mistakes, instead, make sure that every bug is noticeable. That way you will learn more and thinks that you did not know (that’s why bug occurs).

Bugs that I have meet are something like: user uploaded file with non asci chars, linkedin identity text is missing graduation date, after_create hook on model with devise cause user.save to return true but user.errors is present and user is redirected to update his registration form… You can NOT write tests for those situations. Better is to have nice notification with all input/session/user data. Any rescue block should use this notification.

Excellent gem for notification is exception_notification. It is rack middleware and configuration is very simple.

Basic installation

After adding to your Gemfile

cat >> Gemfile <<HERE_DOC
# error notification to EXCEPTION_RECIPIENTS emails
gem 'exception_notification'
HERE_DOC
bundle

set email receivers in your secrets:

sed -i config/secrets.yml -e '/^shared:/a \
  # for all outgoing emails\
  mailer_sender: <%= ENV["MAILER_SENDER"] || "My Company <support@example.com>" %>\
\
  # leave this empty if you do not want to enable server error notifications\
  # othervise comma separated emails\
  exception_recipients: <%= ENV["EXCEPTION_RECIPIENTS"] %>\
'

and add this initialization file (similar to autogenerated with rails g exception_notification:install)

cat > config/initializers/exception_notification.rb << HERE_DOC
require 'exception_notification/rails'

class Notify
  # Send notification using string as subject and pass additional argumets (it
  # will be shown as array) or options (shown as hash) for example:
  # Notify.message 'some_problem', customers_url, email: customer.email
  def self.message(message, *args)
    data = {}
    # put first args so it is shown at beginning
    data[:args] = args if args.present?
    data.merge! args.extract_options!
    ExceptionNotifier.notify_exception(Exception.new(message), data: data)
    # return true so we could use: Notify.message 'hi' and return unless continue?
    true
  end

  def self.exception_with_env(message, args)
    # set env to nil if you do not want sections: Request, Environment, Session
    # backtrace is not shown for manual notification (only if data section
    # contains backtrace)
    # data section is always shown if exists
    params = {
      env: args[:env],
      exception_recipients: args[:exception_recipients],
      email_prefix: args[:email_prefix],
      data: args[:data],
    }.delete_if { |_k, v| v.nil? }
    ExceptionNotifier.notify_exception(Exception.new(message), params)
  end
end

# rubocop:disable Metrics/BlockLength
if (receivers = Rails.application.secrets.exception_recipients).present?
  ExceptionNotification.configure do |config|
    # Ignore additional exception types. Those are already included
    # ActiveRecord::RecordNotFound
    # AbstractController::ActionNotFound
    # ActionController::RoutingError
    config.ignored_exceptions += %w[
      ActiveRecord::RecordNotFound
      AbstractController::ActionNotFound
      ActionController::RoutingError
      ActionController::UnknownFormat

      ApplicationController::NotFoundError
      ApplicationController::DisabledError
      ApplicationController::AccessDeniedError
      ApplicationController::InvalidCharactersUsed
    ]

    # Adds a condition to decide when an exception must be ignored or not.
    # The ignore_if method can be invoked multiple times to add extra conditions
    # config.ignore_if do |exception, options|
    #   not Rails.env.production?
    # end

    # Ignore crawlers
    IGNORE_HTTP_USER_AGENT = %w[
      Googlebot bingbot linkdexbot Baiduspider YandexBot panscient.com MJ12bot
      SeznamBot
    ].freeze
    config.ignore_if do |_exception, options|
      options[:env] && Array(IGNORE_HTTP_USER_AGENT).any? do |crawler|
        options[:env]['HTTP_USER_AGENT'] =~ Regexp.new(crawler)
      end
    end

    # Ignore formats
    IGNORE_HTTP_ACCEPT = %w[Agent-007 image/x-xbitmap].freeze
    config.ignore_if do |_exception, options|
      options[:env] && Array(IGNORE_HTTP_ACCEPT).any? do |format|
        options[:env]['HTTP_ACCEPT'] =~ Regexp.new(format)
      end
    end

    # Ignore too much notifications. throttle by same message
    THROTTLE_LIMIT = 3 # resets only when nothing is seen in interval window
    THROTTLE_INTERVAL_SECONDS = 1.hour
    config.ignore_if do |exception|
      # to exit from proc we could use 'next' ('return' or 'break' does not work
      # for proc) but than it returns nil, ie it will notify if true
      # to skip notification on development clear EXCEPTION_RECIPIENTS env
      cache_key = exception.message
      cache_key.sub!(/0[xX][0-9a-fA-F]+/, '') # ignore eventual object hex id
      already = Rails.cache.fetch(cache_key)
      if already
        Rails.cache.write cache_key,
                          already + 1,
                          expires_in: THROTTLE_INTERVAL_SECONDS
        # do not notify if already send max number of times, return val is true
        already >= THROTTLE_LIMIT
      else
        Rails.cache.write cache_key, 1, expires_in: THROTTLE_INTERVAL_SECONDS
        # it is ok to notify
        false
      end
    end

    # Ignore specific exceptions that are marked to be ignored
    # begin
    # rescue StandardError => e
    #   e.ignore_please = true
    #   raise e
    # end
    config.ignore_if { |e| e.respond_to?(:ignore_please) && e.ignore_please }

    # Notifiers ================================================================

    # Email notifier sends notifications by email.
    config.add_notifier :email,
                        email_prefix: '[MyApp] ',
                        sender_address: Rails.application.secrets.mailer_sender,
                        exception_recipients: receivers.split(',')
  end
end
HERE_DOC

This will send email for any exception if EXCEPTION_RECIPIENTS are present. As delivery method you can use very nice letter_opener for development.

If you keep receiving unknown (ActionView::MissingTemplate) "Missing template for json request (but you only consider html) or for html (but you only consider js), you should add something like

  def index
    respond_to do |format|
      format.html
      format.any { redirect_to root_path }
    end
  end

You can test if it properly ignore by setting curl headers

curl http://localhost:3000/sample-error # this should open letter opener
curl http://localhost:3000/sample-error -A 'Googlebot' # this is ignored
curl http://localhost:3000/sample-error -H 'Accept: Agent-007' # this is ignored
curl  -H "Accept: application/json" http://localhost:3000/ # could trigger
# MissingTemplate error

Javascript notification and example error pages

I would add two pages sample-error and sample-error-in-javascript just to have some pages for test if this notification works. First create routes

# config/routes.rb
get 'sample-error', to: 'pages#sample_error'
get 'sample-error-in-javascript', to: 'pages#sample_error_in_javascript'
get 'sample-error-in-javascript-ajax', to: 'pages#sample_error_in_javascript_ajax'
post 'notify-javascript-error', to: 'pages#notify_javascript_error'
get 'sample-error-in-resque', to: 'pages#sample_error_in_resque'
get 'sample-error-in-sidekiq', to: 'pages#sample_error_in_sidekiq'
get 'sample-error-in-delayed-job', to: 'pages#sample_error_in_delayed_job'

than controller method that we will use for manual notification ExceptionNotifier.notify_exception In some cases I need just to notify with custom email MyMailer.internal_notification(subject, item).deliver

# app/mailers/my_mailer.rb
class MyMailer < ActionMailer::Base
  def internal_notification(subject, item)
    mail to: INTERNAL_NOTIFICATION_EMAIL,
         subject: "[MyApp info] #{subject}",
         body: "<h1>#{subject}</h1><strong>Details:</strong>" +
               item
               .inspect
               .gsub(', ', ',<br>')
               .gsub('{', '<br>{<br>')
               .gsub('}', '<br>}<br>'),
         content_type: 'text/html'
  end
end

For notify_exception can pass additional information using :data param. Only the first argument is required (default values you can find here). For less important notification you can change subject with email_prefix param. Manual notification can be simply as one line ExceptionNotifier.notify_exception(Exception.new('this_user_is_deactived'), env: request.env, email_prefix: 'just to notify that', data: { current_user: current_user });. Here is what I use:

# app/controllers/pages_controller.rb
class PagesController < ApplicationController
  skip_before_action :verify_authenticity_token, only: %i[
    notify_javascript_error
  ]

  def sample_error
    raise 'This is sample_error on server'
  end

  def sample_error_in_javascript
    render layout: true, html: %(
      Calling manual_js_error_now
      <script>
        function manual_js_error_now_function() {
          manual_js_error_now
        }
        console.log('calling manual_js_error_now');
        manual_js_error_now_function();
        // you can also trigger error on window.onload = function() { manual_js_error_onload }
      </script>
      <br>
      <button onclick="trigger_js_error_on_click">Trigger error on click</button>
      <a href="/sample-error-in-javascript-ajax" data-remote="true" id="l">Trigger error in ajax</a>
    ).html_safe
  end

  def sample_error_in_javascript_ajax
    render js: %(
      console.log("starting sample_error_in_javascript_ajax");
      sample_error_in_javascript_ajax
    )
  end

  def notify_javascript_error
    js_receivers = Rails.application.secrets.javascript_error_recipients
    if js_receivers.present?
      ExceptionNotifier.notify_exception(
        Exception.new(params[:errorMsg]),
        env: request.env,
        exception_recipients: js_receivers.to_s.split(','),
        data: {
          current_user: current_user,
          params: params
        }
      )
    end
    head :ok
  end

  def sample_error_in_resque
    Resque.enqueue(TaskWithError)
    render plain: 'TaskWithError in queue, please run: QUEUE=* rake resque:work'
  end

  def sample_error_in_delayed_job
    SampleErrorJob.perform_later
    render plain: 'SampleErrorJob in queue, please run: QUEUE=* rake jobs:work'
  end
end

class TaskWithError
  @queue = :test
  def self.perform
    raise 'This is sample_error_in_resque'
  end
end

Delayed job

# config/initializers/delayed_job.rb
Delayed::Worker.destroy_failed_jobs = false
Delayed::Worker.max_attempts = 3
Delayed::Worker.delay_jobs = !Rails.env.test?

### RAILS 5
module CustomFailedJob
  def handle_failed_job(job, error)
    super
    ExceptionNotifier.notify_exception(error, data: {job: job})
  end
end

class Delayed::Worker
  prepend CustomFailedJob
end

### Rails 4

Delayed::Worker.logger = Logger.new(File.join(Rails.root, 'log', 'delayed_job.log'))

# when you change this file, make sure that you restart delayed_job process
# bin/delayed_job stop && bin/delayed_job start
# probably DelayedJob is configured to retry 3 times, so you will receive
# notification emails
# do not rescue in worker because no notification email will be send
# http://andyatkinson.com/blog/2014/05/03/delayed-job-exception-notification-integration

class Exception
  attr_accessor :ignore_please
end

# Chain delayed job's handle_failed_job method to do exception notification
Delayed::Worker.class_eval do
  def handle_failed_job_with_notification(job, error)
    handle_failed_job_without_notification(job, error)

    begin
      # ExceptionNotifier.notify_exception(error) do not use standard notification, use delayed_job partial
      return if error.ignore_please && job.attempts < Delayed::Worker.max_attempts
      env = {}
      env['exception_notifier.options'] = {
        sections: %w(backtrace delayed_job),
        email_prefix: "[Xceednet Delayed Job Exception] ",
      }
      env['exception_notifier.exception_data'] = {job: job}
      ExceptionNotifier::Notifier.exception_notification(env, error).deliver

    # rescue if ExceptionNotifier fails for some reason
    rescue Exception => e
      Rails.logger.error "ExceptionNotifier failed: #{e.class.name}: #{e.message}"
      e.backtrace.each do |f|
        Rails.logger.error "  #{f}"
      end
      Rails.logger.flush
    end
  end

  alias_method_chain :handle_failed_job, :notification
end

If you want to ignore specific timeout exception than use something like

# app/jobs/send_sms_job.rb
class SendSmsJob < ActiveJob::Base
  queue_as :webapp

  rescue_from Net::ReadTimeout, SocketError do |e|
    e.ignore_please = true
    # re-raise so job is retried
    raise e
  end
  def perform()
  end
end
# app/jobs/sample_error_job.rb
class SampleErrorJob < ActiveJob::Base
  queue_as :default

  def perform
    raise 'This is sample_error_in_delayed_job'
  end
end

Sidekiq

# app/jobs/task_with_error_job.rb
class TaskWithErrorJob < ApplicationJob
  queue_as :default
  def perform
    raise 'This is sample_error_in_sidekiq'
  end
end

# config/initializers/exception_notification.rb
module ExceptionNotification
  ::Sidekiq.configure_server do |config|
    config.error_handlers << proc { |ex, context|
      ExceptionNotifier.notify_exception(ex, data: { sidekiq: context })
    }
  end
end

If error occurs in ajax reponse, or in some of your javascript code, we will send another request to server to trigger javascript notification. Note that you can log with console.error("Some error message") (in console it will look like exception) but no notification will be sent.

You can send notifications using formspree service. There is non jQuery fallback but loaded jQuery is preferred. You should create separate file that will be loaded in <head> and before application.js so it is loaded before any other js code.

// app/assets/javascripts/exception_notification.js.erb
// This should be loaded in <head> and separatelly from application.js
// notification will not work if some error exist in this file
//
var MAX_NUMBER_OF_JS_ERROR_NOTIFICATIONS = 5;

function sendExceptionNotification(data) {
  <% unless Rails.application.secrets.javascript_error_recipients.present? %>
    return ;
  <% end %>
  // maybe error occured before jQuery was loaded
  if (window.jQuery) {
    console.log("notify server about exception using jQuery");
    jQuery.post('/notify-javascript-error', data);

  } else {
    console.log("notify server about exception using plain javascript");
    var xhr = new XMLHttpRequest();
    xhr.open('POST', '/notify-javascript-error', true);
    xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
    xhr.send(JSON.stringify(data));
  }
  // or use formspree service with your email
  // $.ajax({
  //   url: "https://formspree.io/your@gmail.com", 
  //   method: "POST",
  //   data: data,
  //   dataType: "json"
  // });
}

function ignoreErrorMsg(errorMsg) {
  if (errorMsg.length == 0) return true; // no need to notify empty message
  <% if Rails.env.development? %>
      return false; // always notify on development
  <% end %>
  if (sessionStorage) {
    var errorMsgs = JSON.parse(sessionStorage.getItem("errorMsgs") || "[]");
    if (errorMsgs.indexOf(errorMsg) != -1) {
      console.log("Ignore already notified error for this session and tab");
      return true;
    } else {
      sessionStorage.setItem("errorMsgs", JSON.stringify(errorMsgs + [errorMsg]));
    }

    if (JSON.parse(sessionStorage.getItem("numberOfJsErrorNotifications") || "0") >= MAX_NUMBER_OF_JS_ERROR_NOTIFICATIONS) {
      console.log("Ignore error since number of notification reached maxiumum " + MAX_NUMBER_OF_JS_ERROR_NOTIFICATIONS);
      return true;
    }
  }
  ignoredErrors = {
    // https://github.com/kogg/InstantLogoSearch/issues/199
    tgt: "Cannot set property 'tgt' of null",
    // from extention http://s3.amazonaws.com/js-cache/ddc1b879c920534271.js
    partnrz: "Unexpected token < in JSON at position 1",
    // from extension http://s3.amazonaws.com/jscache/de53b459ee43e7774f.js
    monetize: "SyntaxError: Unexpected end of JSON input",
    plugin1: "TypeError: undefined is not an object (evaluating 'Window.prototype.setTimeout.call')",
    deals: "Uncaught ReferenceError: US is not defined",
    // http://pastebin.com/JAPbmEX6
    reno: "renoTransGloRef",
    // https://bugs.chromium.org/p/chromium/issues/detail?id=590375
    chrome1: "__gCrWeb",
    // unknown string closingEls
    closing_els: "TypeError: Cannot read property 'closingEls' of undefined",
    // some unknown
    show_deepen: "__show__deepen",
    // __firefox__.favicons.getFavicons
    firefox: "__firefox__",
    // unknown
    unknown1: "viewWillDisappear",
  }
  for (var key in ignoredErrors) {
    if (errorMsg.indexOf(ignoredErrors[key]) != -1) {
      console.log("ignoredErrors key=" + key);
      return true;
    }
  }
  return false;
}

function ignoreSourceUrl(sourceUrl) {
if (typeof(sourceUrl) == "string" && sourceUrl != "" &&
    sourceUrl.indexOf(window.location.hostname) == -1) {
    console.log("ignoreSourceUrl");
    return true;
  }
  return false;
}

function ignoreStack(stack) {
  if (stack == null) {
    return false;
  }
  ignoredStacks = {
    akamai: "akamaihd.net",
  }
  for (var key in ignoredStacks) {
    if (stack.indexOf(ignoredStacks[key]) != -1) {
      console.log("ignoredStacks key=" + key);
      return true;
    }
  }
  return false;
}

function checkAndSendNotification(notificationData) {
  var errorMsg = notificationData.errorMsg;
  if (ignoreSourceUrl(notificationData.sourceUrl)) return;
  if (ignoreStack(notificationData.stack)) return;
  if (ignoreErrorMsg(errorMsg)) return;
  flash_alert(errorMsg);
  sendExceptionNotification(notificationData);
  if (sessionStorage) {
    var numberOfJsErrorNotifications = JSON.parse(sessionStorage.getItem("numberOfJsErrorNotifications") || "0");
    numberOfJsErrorNotifications += 1;
    sessionStorage.setItem("numberOfJsErrorNotifications", JSON.stringify(numberOfJsErrorNotifications));
  }
}

// https://developer.mozilla.org/en/docs/Web/API/GlobalEventHandlers/onerror
// https://blog.getsentry.com/2016/01/04/client-javascript-reporting-window-onerror.html
window.onerror = function(errorMsg, sourceUrl, lineNumber, column, errorObj) {
  if (errorObj != null) {
    errorMsg = errorObj.toString();
  }
  var stack;
  if (errorObj == null || errorObj.stack == null) {
    stack = new Error().stack;
  } else {
    stack = errorObj.stack
  }
  var notificationData = { errorMsg: errorMsg, sourceUrl: sourceUrl, lineNumber: lineNumber, column: column, stack: stack };
  checkAndSendNotification(notificationData);
}

// another approach is with error event listener
// window.addEventListener('error', function (e) {
//     var stack = e.error.stack;
//     var message = e.error.toString();
// });

// ajax error handling
// wait DOM to load
// http://stackoverflow.com/questions/799981/document-ready-equivalent-without-jquery
// I tried with document.addEventListener("DOMContentLoaded", function(event) {
// but $ still not defined
function listenAjaxErrors() {
 // https://github.com/rails/jquery-ujs/wiki/ajax
 $(document).on('ajax:error', '[data-remote]', function(e, xhr, status, errorObj) {
   flash_alert("Please refresh the page. Server responds with: " + errorObj);
   var notificationData = { errorMsg: errorObj.toString(), status: status, stack: errorObj.stack };
   checkAndSendNotification(notificationData);
 });
}

// http://stackoverflow.com/questions/7486309/how-to-make-script-execution-wait-until-jquery-is-loaded
function defer(method) {
  if (window.jQuery)
    method();
  else
    setTimeout(function() { defer(method) }, 150);
}

defer(listenAjaxErrors);

function flash_alert(msg) {
  if (msg.length == 0) return;
  // disable eventual popups so user can see the message
  // $('.active').removeClass('active');
  // alert(msg);
  console.log(msg);
}

// Ensures there will be no 'console is undefined' errors
// http://stackoverflow.com/questions/9725111/internet-explorer-console-is-not-defined-error
window.console = window.console || (function(){
    var c = {}; c.log = c.warn = c.debug = c.info = c.error = c.time = c.dir = c.profile = c.clear = c.exception = c.trace = c.assert = function(s){};
    return c;
})();
  • if your use javascript assets that put all js to application.js or your js is loaded at the end of <body> (not included in <head>) than you need to include this exception notification so it is available BEFORE any other js code. In this case you need to stub it so it is not included twice

    # app/views/layouts/application.html.erb
      <head>
        <%= javascript_include_tag :exception_notification %>
        <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
      </head>
    
    // app/assets/javascripts/application.js
    //= stub exception_notification
    
    # config/initializers/assets.rb
    Rails.application.config.assets.precompile += %w[exception_notification.js]
    

Do not forget to define javascript receivers

# config/secrets.yml
  # leave this empty if you do not want to enable javascript error notification
  # othervise comma separated emails
  javascript_error_recipients: <%= ENV["JAVASCRIPT_ERROR_RECIPIENTS"] %>

NOTE that when you export JAVASCRIPT_ERROR_RECIPIENTS than you need also to change app/assets/javascripts/exception_notification.js.erb so it is recompiled (touch does not trigger recompilation).

CSP can help your site to prevent loading external js for extensions. Fine grane can enable loading google maps, facebook buttons on specific pages.

Content-Security-Policy: default-src

Custom Templates

You can change existing sections like https://github.com/smartinez87/exception_notification/blob/master/lib/exception_notifier/views/exception_notifier/_session.text.erb by creating same file like

# app/views/exception_notifier/_session.text.erb
* session id: <%= @request.ssl? ? "[FILTERED]" : (raw (@request.session['session_id'] || (@request.env["rack.session.options"] and @request.env["rack.session.options"][:id])).inspect.html_safe) %>
* data: <%= raw PP.pp(@request.session.to_hash, "") %>

<% if @request.session['warden.user.user.key'].present? %>
<%
  id = @request.session['warden.user.user.key']&.first&.first
  user = User.find_by id: id
%>
  <% if user %>
  * user <%= user.email %>
  <% else %>
  * user can not be found
  <% end %>
<% end %>

You can also set some new sections with custom sections. You can set sections in manual notification or inside settings->email. You need to write text partial (html is not supported) in which you can access to @data,@request… variables, which you can list with <%= instance_variables %>

# app/views/exception_notifier/_message.text.erb
Javascript error params
<%=raw @data[:params].inspect %>
<br>
HTTP_USER_AGENT=<%= @request.env["HTTP_USER_AGENT"] %>
<br>
HTTP_ACCEPT=<%= @request.env["HTTP_ACCEPT"] %>
<br>
REMOTE_ADDR=<%= @request.env["REMOTE_ADDR"] %>
session
# app/views/exception_notifier/_delayed_job.text.erb
JOB
<%= instance_variable_get(:@job).inspect %>

Render custom error pages

If you want to render error-page and page-not-found with rails you can rescue from all StandardError exceptions.

# app/controllers/application_controller.rb
  if Rails.application.secrets.exception_recipients.present?
    rescue_from StandardError do |exception|
      case exception.class
      when ActiveRecord::RecordNotFound,
          ActionController::RoutingError,
          ActionController::UnknownController,
          ActionController::MethodNotAllowed
        redirection_path = page_not_found_path
      when ActionController::InvalidAuthenticityToken
        redirection_path = new_user_session_path
      else
        redirection_path = error_page_path
      end
      ExceptionNotifier.notify_exception(exception,
                                         env: request.env,
                                         data: { current_user: current_user }
                                        )
      Rails.logger.error exception.backtrace.join("\n")
      Rails.logger.error exception.message
      flash[:alert] = exception.message if exception.message.present?
      respond_to do |format|
        format.html { redirect_to redirection_path }
        format.js do
          render text: "window.location.assign('#{redirection_path}');"
        end
        format.json { render nothing: true }
        format.text { render nothing: true }
        format.csv { render nothing: true }
      end
    end
  end

And you should create routes for those page_not_found_path and error_page_path and nice templates as well.

# config/routes.rb
get 'error-page', to: 'pages'
get 'page-not-found', to: 'pages'
get 'javascript-required-page', to: 'pages#javascript_required_page', as: :javascript_required_page

Javascript required page is not needed since all browser use javascript nowadays. But if you really want to show that notification use this in layout file, for pages after user logs in (and search bots does not).

# app/views/layout/application.html.erb
<% if params[:controller] == "requests" || params[:controller] == "contacts" %>
  <noscript>
    <meta http-equiv="refresh" content="2;url=/javascript-required-page">
  </noscript>
<% end %>

Resque

Notifications in resque could be generated with

rails g exception_notification:install --resque

or better is to insert on existing config:

# config/initializers/exception_notification.rb
require 'resque/failure/multiple'
require 'resque/failure/redis'
require 'exception_notification/resque'

Resque::Failure::Multiple.classes = [
  Resque::Failure::Redis, ExceptionNotification::Resque
]
Resque::Failure.backend = Resque::Failure::Multiple

Note that you need to export EXCEPTION_RECIPIENTS=asd@asd.asd in shell where you run QUEUE=* rake resque:work for example:

EXCEPTION_RECIPIENTS=asd@asd.asd QUEUE=* rake resque:work

Note that this notifications will be triggered for jobs that are inserted using resque-scheduler-for-reurring-tasks

Rake

When you are using heroku scheduler to run rake tasks, you can add notification there also. To see output/log of some rake task, you should use heroku run:detached rake routes instead of heroku run rake routes devcenter.heroku.

Exception notifications can be used there also with this exception_notification-rake but it does not work for rails 5, so better is to manually patch Rake::Task

cat >> config/initializers/task.rb << HERE_DOC
# http://stackoverflow.com/questions/7161374/rails-exception-notifier-in-rake-tasks
require 'rake/task'
# rubocop:disable Lint/RescueException
module Rake
  class Task
    alias orig_execute execute
    def execute(args = nil)
      orig_execute(args)
    rescue Exception => exception
      ExceptionNotifier.notify_exception(exception)
    end
  end
end
HERE_DOC

You can test with this failing test

cat >> lib/tasks/my_task.rake << HERE_DOC
namespace :my_task do
  desc "my task"
  task :run => :environment do
    raise "this is my exception"
  end
end
HERE_DOC

and run with EXCEPTION_RECIPIENTS=asd@asd.asd rake my_task:run and you should see the email.

To test in minitest or rspec you can use https://stackoverflow.com/questions/34862667/test-exceptionnotification-middleware-in-rails-unit-test https://agileleague.com/blog/rails-3-2-custom-error-pages-the-exceptions_app-and-testing-with-capybara/ I prefer to test only routing to /404 /422 /500 Note that you should js: true.

config.consider_all_requests_local = false
config.action_dispatch.show_exceptions = true

Usually you need to export export EXCEPTION_RECIPIENTS=asd@asd.asd in the same process where it is run (rails s for immediate exceptions, rake resque:work for exceptions in background jobs, rake resque:scheduler usually do not raise exception, it justs enque jobs, rake my_task:run for manual invoke rake tasks).

Deliver later

If you use ActiveJob than you can try to deliver later but there are some issues https://github.com/smartinez87/exception_notification/issues/319

I receive error An error occurred when sending a notification using 'email' notifier. ActiveJob::SerializationError: Unsupported argument type: IO