├── .github └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── gemfiles └── services.gemfile ├── lib ├── safely │ ├── core.rb │ ├── services.rb │ └── version.rb └── safely_block.rb ├── safely_block.gemspec └── test ├── env_test.rb ├── safely_test.rb ├── services_test.rb ├── tag_test.rb └── test_helper.rb /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | ruby: [3.4, 3.3, 3.2] 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: ruby/setup-ruby@v1 13 | with: 14 | ruby-version: ${{ matrix.ruby }} 15 | bundler-cache: true 16 | - run: bundle exec rake test 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | *.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.bundle 19 | *.so 20 | *.o 21 | *.a 22 | *.log 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.5.0 (2025-05-26) 2 | 3 | - Dropped support for Ruby < 3.2 4 | 5 | ## 0.4.1 (2024-09-04) 6 | 7 | - Added support for Datadog 8 | 9 | ## 0.4.0 (2023-05-07) 10 | 11 | - Added exception reporting from [Errbase](https://github.com/ankane/errbase) 12 | - Dropped support for Ruby < 3 13 | 14 | ## 0.3.0 (2019-10-28) 15 | 16 | - Made `safely` method private to behave like `Kernel` methods 17 | 18 | ## 0.2.2 (2019-08-06) 19 | 20 | - Added `context` option 21 | 22 | ## 0.2.1 (2018-02-25) 23 | 24 | - Tag exceptions reported with `report_exception` 25 | 26 | ## 0.2.0 (2017-02-21) 27 | 28 | - Added `tag` option to `safely` method 29 | - Switched to keyword arguments 30 | - Fixed frozen string error 31 | - Fixed tagging with custom error handler 32 | 33 | ## 0.1.1 (2016-05-14) 34 | 35 | - Added `Safely.safely` to not pollute when included in gems 36 | - Added `throttle` option 37 | 38 | ## 0.1.0 (2015-03-15) 39 | 40 | - Added `tag` option and tag exception message by default 41 | - Added `except` option 42 | - Added `silence` option 43 | 44 | ## 0.0.4 (2015-03-11) 45 | 46 | - Added fail-safe 47 | 48 | ## 0.0.3 (2014-08-13) 49 | 50 | - Added `safely` method 51 | 52 | ## 0.0.2 (2014-08-12) 53 | 54 | - Added `default` option 55 | - Added `only` option 56 | 57 | ## 0.0.1 (2014-08-12) 58 | 59 | - First release 60 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "minitest" 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2025 Andrew Kane 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Safely 2 | 3 | ```ruby 4 | safely do 5 | # keep going if this code fails 6 | end 7 | ``` 8 | 9 | Exceptions are rescued and automatically reported to your favorite reporting service. 10 | 11 | In development and test environments, exceptions are raised so you can fix them. 12 | 13 | [Read more](https://ankane.org/safely-pattern) 14 | 15 | [![Build Status](https://github.com/ankane/safely/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/safely/actions) 16 | 17 | ## Installation 18 | 19 | Add this line to your application’s Gemfile: 20 | 21 | ```ruby 22 | gem "safely_block" 23 | ``` 24 | 25 | ## Use It Everywhere 26 | 27 | “Oh no, analytics brought down search” 28 | 29 | ```ruby 30 | safely { track_search(params) } 31 | ``` 32 | 33 | “Recommendations stopped updating because of one bad user” 34 | 35 | ```ruby 36 | users.each do |user| 37 | safely(context: {user_id: user.id}) { update_recommendations(user) } 38 | end 39 | ``` 40 | 41 | Also aliased as `yolo` 42 | 43 | ## Features 44 | 45 | Pass extra context to be reported with exceptions 46 | 47 | ```ruby 48 | safely context: {user_id: 123} do 49 | # code 50 | end 51 | ``` 52 | 53 | Specify a default value to return on exceptions 54 | 55 | ```ruby 56 | show_banner = safely(default: true) { show_banner_logic } 57 | ``` 58 | 59 | Raise specific exceptions 60 | 61 | ```ruby 62 | safely except: ActiveRecord::RecordNotUnique do 63 | # all other exceptions will be rescued 64 | end 65 | ``` 66 | 67 | Pass an array for multiple exception classes. 68 | 69 | Rescue only specific exceptions 70 | 71 | ```ruby 72 | safely only: ActiveRecord::RecordNotUnique do 73 | # all other exceptions will be raised 74 | end 75 | ``` 76 | 77 | Silence exceptions 78 | 79 | ```ruby 80 | safely silence: ActiveRecord::RecordNotUnique do 81 | # code 82 | end 83 | ``` 84 | 85 | Throttle reporting with: 86 | 87 | ```ruby 88 | safely throttle: {limit: 10, period: 1.minute} do 89 | # reports only first 10 exceptions each minute 90 | end 91 | ``` 92 | 93 | **Note:** The throttle limit is approximate and per process. 94 | 95 | ## Reporting 96 | 97 | Reports exceptions to a variety of services out of the box. 98 | 99 | - [Airbrake](https://airbrake.io/) 100 | - [Appsignal](https://appsignal.com/) 101 | - [Bugsnag](https://bugsnag.com/) 102 | - [Datadog](https://www.datadoghq.com/product/error-tracking/) 103 | - [Exception Notification](https://github.com/kmcphillips/exception_notification) 104 | - [Google Stackdriver](https://cloud.google.com/stackdriver/) 105 | - [Honeybadger](https://www.honeybadger.io/) 106 | - [New Relic](https://newrelic.com/) 107 | - [Raygun](https://raygun.io/) 108 | - [Rollbar](https://rollbar.com/) 109 | - [Scout APM](https://scoutapm.com/) 110 | - [Sentry](https://getsentry.com/) 111 | 112 | **Note:** Context is not supported with Google Stackdriver and Scout APM 113 | 114 | Customize reporting with: 115 | 116 | ```ruby 117 | Safely.report_exception_method = ->(e) { Rollbar.error(e) } 118 | ``` 119 | 120 | With Rails, you can add this in an initializer. 121 | 122 | By default, exception messages are prefixed with `[safely]`. This makes it easier to spot rescued exceptions. Turn this off with: 123 | 124 | ```ruby 125 | Safely.tag = false 126 | ``` 127 | 128 | By default, exceptions are raised in the development and test environments. Change this with: 129 | 130 | ```ruby 131 | Safely.raise_envs += ["staging"] 132 | ``` 133 | 134 | To report exceptions manually: 135 | 136 | ```ruby 137 | Safely.report_exception(e) 138 | ``` 139 | 140 | ## Data Protection 141 | 142 | To protect the privacy of your users, do not send [personal data](https://en.wikipedia.org/wiki/Personally_identifiable_information) to exception services. Filter sensitive form fields, use ids (not email addresses) to identify users, and mask IP addresses. 143 | 144 | With Rollbar, you can do: 145 | 146 | ```ruby 147 | Rollbar.configure do |config| 148 | config.person_id_method = "id" # default 149 | config.scrub_fields |= [:birthday] 150 | config.anonymize_user_ip = true 151 | end 152 | ``` 153 | 154 | While working on exceptions, be on the lookout for personal data and correct as needed. 155 | 156 | ## History 157 | 158 | View the [changelog](https://github.com/ankane/safely/blob/master/CHANGELOG.md) 159 | 160 | ## Contributing 161 | 162 | Everyone is encouraged to help improve this project. Here are a few ways you can help: 163 | 164 | - [Report bugs](https://github.com/ankane/safely/issues) 165 | - Fix bugs and [submit pull requests](https://github.com/ankane/safely/pulls) 166 | - Write, clarify, or fix documentation 167 | - Suggest or add new features 168 | 169 | To get started with development and testing: 170 | 171 | ```sh 172 | git clone https://github.com/ankane/safely.git 173 | cd safely 174 | bundle install 175 | bundle exec rake test 176 | ``` 177 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | task default: :test 5 | Rake::TestTask.new do |t| 6 | t.libs << "test" 7 | t.pattern = "test/**/*_test.rb" 8 | end 9 | -------------------------------------------------------------------------------- /gemfiles/services.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest", ">= 5" 7 | 8 | gem "airbrake" 9 | gem "appsignal" 10 | gem "bugsnag" 11 | gem "ddtrace", require: "ddtrace/auto_instrument" 12 | gem "exception_notification" 13 | gem "google-cloud-error_reporting" 14 | gem "honeybadger" 15 | gem "newrelic_rpm" 16 | gem "raygun4ruby" 17 | gem "rollbar" 18 | gem "scout_apm" 19 | gem "sentry-raven" 20 | gem "sentry-ruby" 21 | -------------------------------------------------------------------------------- /lib/safely/core.rb: -------------------------------------------------------------------------------- 1 | # stdlib 2 | require "digest" 3 | 4 | # modules 5 | require_relative "services" 6 | require_relative "version" 7 | 8 | module Safely 9 | class << self 10 | attr_accessor :raise_envs, :tag, :report_exception_method, :throttle_counter 11 | attr_writer :env 12 | 13 | def report_exception(e, tag: nil, context: {}) 14 | tag = Safely.tag if tag.nil? 15 | if tag && e.message 16 | e = e.dup # leave original exception unmodified 17 | message = e.message 18 | e.define_singleton_method(:message) do 19 | "[#{tag == true ? "safely" : tag}] #{message}" 20 | end 21 | end 22 | if report_exception_method.arity == 1 23 | report_exception_method.call(e) 24 | else 25 | report_exception_method.call(e, context) 26 | end 27 | end 28 | 29 | def env 30 | @env ||= ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development" 31 | end 32 | 33 | def throttled?(e, options) 34 | return false unless options 35 | key = "#{options[:key] || Digest::MD5.hexdigest([e.class.name, e.message, e.backtrace.join("\n")].join("/"))}/#{(Time.now.to_i / options[:period]) * options[:period]}" 36 | throttle_counter.clear if throttle_counter.size > 1000 # prevent from growing indefinitely 37 | (throttle_counter[key] += 1) > options[:limit] 38 | end 39 | end 40 | 41 | self.tag = true 42 | self.report_exception_method = DEFAULT_EXCEPTION_METHOD 43 | self.raise_envs = %w(development test) 44 | # not thread-safe, but we don't need to be exact 45 | self.throttle_counter = Hash.new(0) 46 | 47 | module Methods 48 | def safely(tag: nil, sample: nil, except: nil, only: nil, silence: nil, throttle: false, default: nil, context: {}) 49 | yield 50 | rescue *Array(only || StandardError) => e 51 | raise e if Array(except).any? { |c| e.is_a?(c) } 52 | raise e if Safely.raise_envs.include?(Safely.env) 53 | if sample ? rand < 1.0 / sample : true 54 | begin 55 | unless Array(silence).any? { |c| e.is_a?(c) } || Safely.throttled?(e, throttle) 56 | Safely.report_exception(e, tag: tag, context: context) 57 | end 58 | rescue => e2 59 | $stderr.puts "FAIL-SAFE #{e2.class.name}: #{e2.message}" 60 | end 61 | end 62 | default 63 | end 64 | alias_method :yolo, :safely 65 | end 66 | extend Methods 67 | end 68 | -------------------------------------------------------------------------------- /lib/safely/services.rb: -------------------------------------------------------------------------------- 1 | module Safely 2 | DEFAULT_EXCEPTION_METHOD = proc do |e, info| 3 | begin 4 | Airbrake.notify(e, info) if defined?(Airbrake) 5 | 6 | if defined?(Appsignal) 7 | if Appsignal::VERSION.to_i >= 3 8 | Appsignal.send_error(e) do |transaction| 9 | transaction.set_tags(info) 10 | end 11 | else 12 | Appsignal.send_error(e, info) 13 | end 14 | end 15 | 16 | if defined?(Bugsnag) 17 | Bugsnag.notify(e) do |report| 18 | report.add_tab(:info, info) if info.any? 19 | end 20 | end 21 | 22 | if defined?(Datadog::Tracing) 23 | Datadog::Tracing.active_span&.set_tags(info) 24 | Datadog::Tracing.active_span&.set_error(e) 25 | end 26 | 27 | ExceptionNotifier.notify_exception(e, data: info) if defined?(ExceptionNotifier) 28 | 29 | # TODO add info 30 | Google::Cloud::ErrorReporting.report(e) if defined?(Google::Cloud::ErrorReporting) 31 | 32 | Honeybadger.notify(e, context: info) if defined?(Honeybadger) 33 | 34 | NewRelic::Agent.notice_error(e, custom_params: info) if defined?(NewRelic::Agent) 35 | 36 | Raven.capture_exception(e, extra: info) if defined?(Raven) 37 | 38 | Raygun.track_exception(e, custom_data: info) if defined?(Raygun) 39 | 40 | Rollbar.error(e, info) if defined?(Rollbar) 41 | 42 | if defined?(ScoutApm::Error) 43 | # no way to add context for a single call 44 | # ScoutApm::Context.add(info) 45 | ScoutApm::Error.capture(e) 46 | end 47 | 48 | Sentry.capture_exception(e, extra: info) if defined?(Sentry) 49 | rescue => e 50 | $stderr.puts "[safely] Error reporting exception: #{e.class.name}: #{e.message}" 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/safely/version.rb: -------------------------------------------------------------------------------- 1 | module Safely 2 | VERSION = "0.5.0" 3 | end 4 | -------------------------------------------------------------------------------- /lib/safely_block.rb: -------------------------------------------------------------------------------- 1 | require_relative "safely/core" 2 | 3 | Object.include Safely::Methods 4 | Object.send :private, :safely, :yolo 5 | -------------------------------------------------------------------------------- /safely_block.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/safely/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "safely_block" 5 | spec.version = Safely::VERSION 6 | spec.summary = "Rescue and report exceptions in non-critical code" 7 | spec.homepage = "https://github.com/ankane/safely" 8 | spec.license = "MIT" 9 | 10 | spec.author = "Andrew Kane" 11 | spec.email = "andrew@ankane.org" 12 | 13 | spec.files = Dir["*.{md,txt}", "{lib}/**/*"] 14 | spec.require_path = "lib" 15 | 16 | spec.required_ruby_version = ">= 3.2" 17 | end 18 | -------------------------------------------------------------------------------- /test/env_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class EnvTest < Minitest::Test 4 | def test_development 5 | Safely.env = "development" 6 | assert_raises(Safely::TestError) do 7 | safely do 8 | raise Safely::TestError 9 | end 10 | end 11 | end 12 | 13 | def test_test 14 | Safely.env = "test" 15 | assert_raises(Safely::TestError) do 16 | safely do 17 | raise Safely::TestError 18 | end 19 | end 20 | end 21 | 22 | def test_production 23 | exception = Safely::TestError.new 24 | mock = Minitest::Mock.new 25 | mock.expect :report_exception, nil, [exception] 26 | Safely.report_exception_method = ->(e) { mock.report_exception(e) } 27 | safely(tag: false) do 28 | raise exception 29 | end 30 | assert mock.verify 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/safely_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class SafelyTest < Minitest::Test 4 | def test_yolo 5 | exception = Safely::TestError.new 6 | mock = Minitest::Mock.new 7 | mock.expect :report_exception, nil, [exception] 8 | Safely.report_exception_method = ->(e) { mock.report_exception(e) } 9 | yolo(tag: false) do 10 | raise exception 11 | end 12 | assert mock.verify 13 | end 14 | 15 | def test_context 16 | context = nil 17 | Safely.report_exception_method = ->(e, ctx) { context = ctx } 18 | safely context: {user_id: 123} do 19 | raise Safely::TestError, "Boom" 20 | end 21 | assert_equal ({user_id: 123}), context 22 | end 23 | 24 | def test_return_value 25 | assert_equal 1, safely { 1 } 26 | assert_nil safely { raise Safely::TestError, "Boom" } 27 | end 28 | 29 | def test_default 30 | assert_equal 1, safely(default: 2) { 1 } 31 | assert_equal 2, safely(default: 2) { raise Safely::TestError, "Boom" } 32 | end 33 | 34 | def test_only 35 | assert_nil safely(only: Safely::TestError) { raise Safely::TestError } 36 | assert_raises(RuntimeError, "Boom") { safely(only: Safely::TestError) { raise "Boom" } } 37 | end 38 | 39 | def test_only_array 40 | assert_nil safely(only: [Safely::TestError]) { raise Safely::TestError } 41 | assert_raises(RuntimeError, "Boom") { safely(only: [Safely::TestError]) { raise "Boom" } } 42 | end 43 | 44 | def test_except 45 | assert_raises(Safely::TestError, "Boom") { safely(except: StandardError) { raise Safely::TestError, "Boom" } } 46 | end 47 | 48 | def test_silence 49 | safely(silence: StandardError) { raise Safely::TestError, "Boom" } 50 | assert true 51 | end 52 | 53 | def test_failsafe 54 | Safely.report_exception_method = ->(_) { raise "oops" } 55 | _, err = capture_io do 56 | safely { raise "boom" } 57 | end 58 | assert_equal "FAIL-SAFE RuntimeError: oops\n", err 59 | end 60 | 61 | def test_throttle 62 | assert_count(2) do 63 | 5.times do 64 | safely throttle: {limit: 2, period: 3600} do 65 | raise Safely::TestError 66 | end 67 | end 68 | end 69 | end 70 | 71 | def test_throttle_key 72 | assert_count(4) do 73 | 5.times do |n| 74 | safely throttle: {limit: 2, period: 3600, key: "boom#{n % 2}"} do 75 | raise Safely::TestError 76 | end 77 | end 78 | end 79 | end 80 | 81 | def test_bad_argument 82 | assert_raises(ArgumentError) do 83 | safely(unknown: true) { } 84 | end 85 | end 86 | 87 | def test_respond_to? 88 | refute nil.respond_to?(:safely) 89 | refute nil.respond_to?(:yolo) 90 | assert Safely.respond_to?(:safely) 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/services_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class ServicesTest < Minitest::Test 4 | def setup 5 | skip unless defined?(Airbrake) 6 | end 7 | 8 | def test_default 9 | Safely.report_exception_method = Safely::DEFAULT_EXCEPTION_METHOD 10 | Safely.report_exception(RuntimeError.new("Boom")) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/tag_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class TagTest < Minitest::Test 4 | def test_default 5 | assert_message("[safely] Boom") do 6 | safely { raise Safely::TestError, "Boom" } 7 | end 8 | end 9 | 10 | def test_global 11 | Safely.tag = false 12 | assert_message("Boom") do 13 | safely { raise Safely::TestError, "Boom" } 14 | end 15 | ensure 16 | Safely.tag = true 17 | end 18 | 19 | def test_local 20 | assert_message("[hi] Boom") do 21 | safely(tag: "hi") { raise Safely::TestError, "Boom" } 22 | end 23 | end 24 | 25 | def test_report_exception 26 | assert_message("[safely] Boom") do 27 | begin 28 | raise Safely::TestError, "Boom" 29 | rescue => e 30 | Safely.report_exception(e) 31 | end 32 | end 33 | end 34 | 35 | private 36 | 37 | def assert_message(expected) 38 | ex = nil 39 | Safely.report_exception_method = ->(e) { ex = e } 40 | yield 41 | assert_equal expected, ex.message 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | Bundler.require(:default) 3 | require "minitest/autorun" 4 | require "minitest/pride" 5 | 6 | module Safely 7 | class TestError < StandardError; end 8 | end 9 | 10 | class Minitest::Test 11 | def setup 12 | Safely.env = "production" 13 | Safely.report_exception_method = ->(_) { } 14 | end 15 | 16 | def assert_count(expected) 17 | count = 0 18 | Safely.report_exception_method = ->(_) { count += 1 } 19 | yield 20 | assert_equal expected, count 21 | end 22 | end 23 | --------------------------------------------------------------------------------