airbrake-ruby 4.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (99) hide show
  1. checksums.yaml +7 -0
  2. data/lib/airbrake-ruby.rb +513 -0
  3. data/lib/airbrake-ruby/async_sender.rb +142 -0
  4. data/lib/airbrake-ruby/backtrace.rb +196 -0
  5. data/lib/airbrake-ruby/benchmark.rb +39 -0
  6. data/lib/airbrake-ruby/code_hunk.rb +51 -0
  7. data/lib/airbrake-ruby/config.rb +229 -0
  8. data/lib/airbrake-ruby/config/validator.rb +91 -0
  9. data/lib/airbrake-ruby/deploy_notifier.rb +36 -0
  10. data/lib/airbrake-ruby/file_cache.rb +48 -0
  11. data/lib/airbrake-ruby/filter_chain.rb +95 -0
  12. data/lib/airbrake-ruby/filters/context_filter.rb +29 -0
  13. data/lib/airbrake-ruby/filters/dependency_filter.rb +31 -0
  14. data/lib/airbrake-ruby/filters/exception_attributes_filter.rb +46 -0
  15. data/lib/airbrake-ruby/filters/gem_root_filter.rb +33 -0
  16. data/lib/airbrake-ruby/filters/git_last_checkout_filter.rb +92 -0
  17. data/lib/airbrake-ruby/filters/git_repository_filter.rb +64 -0
  18. data/lib/airbrake-ruby/filters/git_revision_filter.rb +66 -0
  19. data/lib/airbrake-ruby/filters/keys_blacklist.rb +49 -0
  20. data/lib/airbrake-ruby/filters/keys_filter.rb +140 -0
  21. data/lib/airbrake-ruby/filters/keys_whitelist.rb +48 -0
  22. data/lib/airbrake-ruby/filters/root_directory_filter.rb +28 -0
  23. data/lib/airbrake-ruby/filters/sql_filter.rb +104 -0
  24. data/lib/airbrake-ruby/filters/system_exit_filter.rb +23 -0
  25. data/lib/airbrake-ruby/filters/thread_filter.rb +92 -0
  26. data/lib/airbrake-ruby/hash_keyable.rb +37 -0
  27. data/lib/airbrake-ruby/ignorable.rb +44 -0
  28. data/lib/airbrake-ruby/inspectable.rb +39 -0
  29. data/lib/airbrake-ruby/loggable.rb +34 -0
  30. data/lib/airbrake-ruby/monotonic_time.rb +43 -0
  31. data/lib/airbrake-ruby/nested_exception.rb +38 -0
  32. data/lib/airbrake-ruby/notice.rb +162 -0
  33. data/lib/airbrake-ruby/notice_notifier.rb +134 -0
  34. data/lib/airbrake-ruby/performance_breakdown.rb +45 -0
  35. data/lib/airbrake-ruby/performance_notifier.rb +125 -0
  36. data/lib/airbrake-ruby/promise.rb +109 -0
  37. data/lib/airbrake-ruby/query.rb +53 -0
  38. data/lib/airbrake-ruby/request.rb +45 -0
  39. data/lib/airbrake-ruby/response.rb +74 -0
  40. data/lib/airbrake-ruby/stashable.rb +15 -0
  41. data/lib/airbrake-ruby/stat.rb +73 -0
  42. data/lib/airbrake-ruby/sync_sender.rb +113 -0
  43. data/lib/airbrake-ruby/tdigest.rb +393 -0
  44. data/lib/airbrake-ruby/time_truncate.rb +17 -0
  45. data/lib/airbrake-ruby/timed_trace.rb +58 -0
  46. data/lib/airbrake-ruby/truncator.rb +115 -0
  47. data/lib/airbrake-ruby/version.rb +6 -0
  48. data/spec/airbrake_spec.rb +324 -0
  49. data/spec/async_sender_spec.rb +155 -0
  50. data/spec/backtrace_spec.rb +427 -0
  51. data/spec/benchmark_spec.rb +33 -0
  52. data/spec/code_hunk_spec.rb +115 -0
  53. data/spec/config/validator_spec.rb +184 -0
  54. data/spec/config_spec.rb +154 -0
  55. data/spec/deploy_notifier_spec.rb +48 -0
  56. data/spec/file_cache.rb +36 -0
  57. data/spec/filter_chain_spec.rb +92 -0
  58. data/spec/filters/context_filter_spec.rb +23 -0
  59. data/spec/filters/dependency_filter_spec.rb +12 -0
  60. data/spec/filters/exception_attributes_filter_spec.rb +50 -0
  61. data/spec/filters/gem_root_filter_spec.rb +41 -0
  62. data/spec/filters/git_last_checkout_filter_spec.rb +46 -0
  63. data/spec/filters/git_repository_filter.rb +61 -0
  64. data/spec/filters/git_revision_filter_spec.rb +126 -0
  65. data/spec/filters/keys_blacklist_spec.rb +225 -0
  66. data/spec/filters/keys_whitelist_spec.rb +194 -0
  67. data/spec/filters/root_directory_filter_spec.rb +39 -0
  68. data/spec/filters/sql_filter_spec.rb +219 -0
  69. data/spec/filters/system_exit_filter_spec.rb +14 -0
  70. data/spec/filters/thread_filter_spec.rb +277 -0
  71. data/spec/fixtures/notroot.txt +7 -0
  72. data/spec/fixtures/project_root/code.rb +221 -0
  73. data/spec/fixtures/project_root/empty_file.rb +0 -0
  74. data/spec/fixtures/project_root/long_line.txt +1 -0
  75. data/spec/fixtures/project_root/short_file.rb +3 -0
  76. data/spec/fixtures/project_root/vendor/bundle/ignored_file.rb +5 -0
  77. data/spec/helpers.rb +9 -0
  78. data/spec/ignorable_spec.rb +14 -0
  79. data/spec/inspectable_spec.rb +45 -0
  80. data/spec/monotonic_time_spec.rb +12 -0
  81. data/spec/nested_exception_spec.rb +73 -0
  82. data/spec/notice_notifier_spec.rb +356 -0
  83. data/spec/notice_notifier_spec/options_spec.rb +259 -0
  84. data/spec/notice_spec.rb +296 -0
  85. data/spec/performance_breakdown_spec.rb +12 -0
  86. data/spec/performance_notifier_spec.rb +435 -0
  87. data/spec/promise_spec.rb +197 -0
  88. data/spec/query_spec.rb +11 -0
  89. data/spec/request_spec.rb +11 -0
  90. data/spec/response_spec.rb +88 -0
  91. data/spec/spec_helper.rb +100 -0
  92. data/spec/stashable_spec.rb +23 -0
  93. data/spec/stat_spec.rb +47 -0
  94. data/spec/sync_sender_spec.rb +133 -0
  95. data/spec/tdigest_spec.rb +230 -0
  96. data/spec/time_truncate_spec.rb +13 -0
  97. data/spec/timed_trace_spec.rb +125 -0
  98. data/spec/truncator_spec.rb +238 -0
  99. metadata +213 -0
@@ -0,0 +1,7 @@
1
+ This
2
+ file
3
+ is
4
+ not
5
+ inside
6
+ root
7
+ directory
@@ -0,0 +1,221 @@
1
+ module Airbrake
2
+ ##
3
+ # Represents a chunk of information that is meant to be either sent to
4
+ # Airbrake or ignored completely.
5
+ #
6
+ # @since v1.0.0
7
+ class Notice
8
+ ##
9
+ # @return [Hash{Symbol=>String}] the information about the notifier library
10
+ NOTIFIER = {
11
+ name: 'airbrake-ruby'.freeze,
12
+ version: Airbrake::AIRBRAKE_RUBY_VERSION,
13
+ url: 'https://github.com/airbrake/airbrake-ruby'.freeze
14
+ }.freeze
15
+
16
+ ##
17
+ # @return [Hash{Symbol=>String,Hash}] the information to be displayed in the
18
+ # Context tab in the dashboard
19
+ CONTEXT = {
20
+ os: RUBY_PLATFORM,
21
+ language: "#{RUBY_ENGINE}/#{RUBY_VERSION}".freeze,
22
+ notifier: NOTIFIER
23
+ }.freeze
24
+
25
+ ##
26
+ # @return [Integer] the maxium size of the JSON payload in bytes
27
+ MAX_NOTICE_SIZE = 64000
28
+
29
+ ##
30
+ # @return [Integer] the maximum size of hashes, arrays and strings in the
31
+ # notice.
32
+ PAYLOAD_MAX_SIZE = 10000
33
+
34
+ ##
35
+ # @return [Array<StandardError>] the list of possible exceptions that might
36
+ # be raised when an object is converted to JSON
37
+ JSON_EXCEPTIONS = [
38
+ IOError,
39
+ NotImplementedError,
40
+ JSON::GeneratorError,
41
+ Encoding::UndefinedConversionError
42
+ ].freeze
43
+
44
+ # @return [Array<Symbol>] the list of keys that can be be overwritten with
45
+ # {Airbrake::Notice#[]=}
46
+ WRITABLE_KEYS = %i[notifier context environment session params].freeze
47
+
48
+ ##
49
+ # @return [Array<Symbol>] parts of a Notice's payload that can be modified
50
+ # by the truncator
51
+ TRUNCATABLE_KEYS = %i[errors environment session params].freeze
52
+
53
+ ##
54
+ # @return [String] the name of the host machine
55
+ HOSTNAME = Socket.gethostname.freeze
56
+
57
+ ##
58
+ # @return [String]
59
+ DEFAULT_SEVERITY = 'error'.freeze
60
+
61
+ ##
62
+ # @since v1.7.0
63
+ # @return [Hash{Symbol=>Object}] the hash with arbitrary objects to be used
64
+ # in filters
65
+ attr_reader :stash
66
+
67
+ def initialize(config, exception, params = {})
68
+ @config = config
69
+
70
+ @payload = {
71
+ errors: NestedException.new(config, exception).as_json,
72
+ context: context,
73
+ environment: {
74
+ program_name: $PROGRAM_NAME
75
+ },
76
+ session: {},
77
+ params: params
78
+ }
79
+ @stash = { exception: exception }
80
+ @truncator = Airbrake::Truncator.new(PAYLOAD_MAX_SIZE)
81
+
82
+ extract_custom_attributes(exception)
83
+ end
84
+
85
+ ##
86
+ # Converts the notice to JSON. Calls +to_json+ on each object inside
87
+ # notice's payload. Truncates notices, JSON representation of which is
88
+ # bigger than {MAX_NOTICE_SIZE}.
89
+ #
90
+ # @return [Hash{String=>String}, nil]
91
+ def to_json
92
+ loop do
93
+ begin
94
+ json = @payload.to_json
95
+ rescue *JSON_EXCEPTIONS => ex
96
+ @config.logger.debug("#{LOG_LABEL} `notice.to_json` failed: #{ex.class}: #{ex}")
97
+ else
98
+ return json if json && json.bytesize <= MAX_NOTICE_SIZE
99
+ end
100
+
101
+ break if truncate == 0
102
+ end
103
+ end
104
+
105
+ ##
106
+ # Ignores a notice. Ignored notices never reach the Airbrake dashboard.
107
+ #
108
+ # @return [void]
109
+ # @see #ignored?
110
+ # @note Ignored noticed can't be unignored
111
+ def ignore!
112
+ @payload = nil
113
+ end
114
+
115
+ ##
116
+ # Checks whether the notice was ignored.
117
+ #
118
+ # @return [Boolean]
119
+ # @see #ignore!
120
+ def ignored?
121
+ @payload.nil?
122
+ end
123
+
124
+ ##
125
+ # Reads a value from notice's payload.
126
+ # @return [Object]
127
+ #
128
+ # @raise [Airbrake::Error] if the notice is ignored
129
+ def [](key)
130
+ raise_if_ignored
131
+ @payload[key]
132
+ end
133
+
134
+ ##
135
+ # Writes a value to the payload hash. Restricts unrecognized
136
+ # writes.
137
+ # @example
138
+ # notice[:params][:my_param] = 'foobar'
139
+ #
140
+ # @return [void]
141
+ # @raise [Airbrake::Error] if the notice is ignored
142
+ # @raise [Airbrake::Error] if the +key+ is not recognized
143
+ # @raise [Airbrake::Error] if the root value is not a Hash
144
+ def []=(key, value)
145
+ raise_if_ignored
146
+
147
+ unless WRITABLE_KEYS.include?(key)
148
+ raise Airbrake::Error,
149
+ ":#{key} is not recognized among #{WRITABLE_KEYS}"
150
+ end
151
+
152
+ unless value.respond_to?(:to_hash)
153
+ raise Airbrake::Error, "Got #{value.class} value, wanted a Hash"
154
+ end
155
+
156
+ @payload[key] = value.to_hash
157
+ end
158
+
159
+ private
160
+
161
+ def context
162
+ {
163
+ version: @config.app_version,
164
+ # We ensure that root_directory is always a String, so it can always be
165
+ # converted to JSON in a predictable manner (when it's a Pathname and in
166
+ # Rails environment, it converts to unexpected JSON).
167
+ rootDirectory: @config.root_directory.to_s,
168
+ environment: @config.environment,
169
+
170
+ # Make sure we always send hostname.
171
+ hostname: HOSTNAME,
172
+
173
+ severity: DEFAULT_SEVERITY
174
+ }.merge(CONTEXT).delete_if { |_key, val| val.nil? || val.empty? }
175
+ end
176
+
177
+ def raise_if_ignored
178
+ return unless ignored?
179
+ raise Airbrake::Error, 'cannot access ignored notice'
180
+ end
181
+
182
+ def truncate
183
+ TRUNCATABLE_KEYS.each { |key| @truncator.truncate(self[key]) }
184
+
185
+ new_max_size = @truncator.reduce_max_size
186
+ if new_max_size == 0
187
+ @config.logger.error(
188
+ "#{LOG_LABEL} truncation failed. File an issue at " \
189
+ "https://github.com/airbrake/airbrake-ruby " \
190
+ "and attach the following payload: #{@payload}"
191
+ )
192
+ end
193
+
194
+ new_max_size
195
+ end
196
+
197
+ def extract_custom_attributes(exception)
198
+ return unless exception.respond_to?(:to_airbrake)
199
+ attributes = nil
200
+
201
+ begin
202
+ attributes = exception.to_airbrake
203
+ rescue StandardError => ex
204
+ @config.logger.error(
205
+ "#{LOG_LABEL} #{exception.class}#to_airbrake failed: #{ex.class}: #{ex}"
206
+ )
207
+ end
208
+
209
+ return unless attributes
210
+
211
+ begin
212
+ @payload.merge!(attributes)
213
+ rescue TypeError
214
+ @config.logger.error(
215
+ "#{LOG_LABEL} #{exception.class}#to_airbrake failed:" \
216
+ " #{attributes} must be a Hash"
217
+ )
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1 @@
1
+ loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong line
@@ -0,0 +1,3 @@
1
+ module Banana
2
+ attr_reader :bingo
3
+ end
@@ -0,0 +1,5 @@
1
+ module IgnoredFile
2
+ def ignore_me
3
+ puts 'Anybody here?'
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ module Helpers
2
+ def fixture_path(filename)
3
+ File.expand_path(File.join('spec', 'fixtures', filename))
4
+ end
5
+
6
+ def project_root_path(filename)
7
+ fixture_path(File.join('project_root', filename))
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ RSpec.describe Airbrake::Ignorable do
2
+ let(:klass) do
3
+ mod = subject
4
+ Class.new { include(mod) }
5
+ end
6
+
7
+ it "ignores includee" do
8
+ instance = klass.new
9
+ expect(instance).not_to be_ignored
10
+
11
+ instance.ignore!
12
+ expect(instance).to be_ignored
13
+ end
14
+ end
@@ -0,0 +1,45 @@
1
+ RSpec.describe Airbrake::Inspectable do
2
+ let(:klass) do
3
+ mod = subject
4
+ Class.new do
5
+ include(mod)
6
+
7
+ def initialize
8
+ @config = Airbrake::Config.new
9
+ @filter_chain = nil
10
+ end
11
+ end
12
+ end
13
+
14
+ describe "#inspect" do
15
+ it "displays object information" do
16
+ instance = klass.new
17
+ expect(instance.inspect).to match(/
18
+ #<:0x\w+\s
19
+ project_id=""\s
20
+ project_key=""\s
21
+ host="http.+"\s
22
+ filter_chain=nil>
23
+ /x)
24
+ end
25
+ end
26
+
27
+ describe "#pretty_print" do
28
+ it "displays object information in a beautiful way" do
29
+ q = PP.new
30
+
31
+ instance = klass.new
32
+ # Guarding is needed to fix JRuby failure:
33
+ # NoMethodError: undefined method `[]' for nil:NilClass
34
+ q.guard_inspect_key { instance.pretty_print(q) }
35
+
36
+ expect(q.output).to match(/
37
+ #<:0x\w+\s
38
+ project_id=""\s
39
+ project_key=""\s
40
+ host="http.+"\s
41
+ filter_chain=nil
42
+ /x)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,12 @@
1
+ RSpec.describe Airbrake::MonotonicTime do
2
+ describe ".time_in_ms" do
3
+ it "returns monotonic time in milliseconds" do
4
+ expect(subject.time_in_ms).to be_a(Float)
5
+ end
6
+
7
+ it "always returns time in the future" do
8
+ old_time = subject.time_in_ms
9
+ expect(subject.time_in_ms).to be > old_time
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,73 @@
1
+ RSpec.describe Airbrake::NestedException do
2
+ describe "#as_json" do
3
+ context "given exceptions with backtraces" do
4
+ it "unwinds nested exceptions" do
5
+ begin
6
+ begin
7
+ raise AirbrakeTestError
8
+ rescue AirbrakeTestError
9
+ Ruby21Error.raise_error('bingo')
10
+ end
11
+ rescue Ruby21Error => ex
12
+ nested_exception = described_class.new(ex)
13
+ exceptions = nested_exception.as_json
14
+
15
+ expect(exceptions.size).to eq(2)
16
+ expect(exceptions[0][:message]).to eq('bingo')
17
+ expect(exceptions[1][:message]).to eq('App crashed!')
18
+ expect(exceptions[0][:backtrace]).not_to be_empty
19
+ expect(exceptions[1][:backtrace]).not_to be_empty
20
+ end
21
+ end
22
+
23
+ it "unwinds no more than 3 nested exceptions" do
24
+ begin
25
+ begin
26
+ raise AirbrakeTestError
27
+ rescue AirbrakeTestError
28
+ begin
29
+ Ruby21Error.raise_error('bongo')
30
+ rescue Ruby21Error
31
+ begin
32
+ Ruby21Error.raise_error('bango')
33
+ rescue Ruby21Error
34
+ Ruby21Error.raise_error('bingo')
35
+ end
36
+ end
37
+ end
38
+ rescue Ruby21Error => ex
39
+ nested_exception = described_class.new(ex)
40
+ exceptions = nested_exception.as_json
41
+
42
+ expect(exceptions.size).to eq(3)
43
+ expect(exceptions[0][:message]).to eq('bingo')
44
+ expect(exceptions[1][:message]).to eq('bango')
45
+ expect(exceptions[2][:message]).to eq('bongo')
46
+ expect(exceptions[0][:backtrace]).not_to be_empty
47
+ expect(exceptions[1][:backtrace]).not_to be_empty
48
+ end
49
+ end
50
+ end
51
+
52
+ context "given exceptions without backtraces" do
53
+ it "sets backtrace to nil" do
54
+ begin
55
+ begin
56
+ raise AirbrakeTestError
57
+ rescue AirbrakeTestError => ex2
58
+ ex2.set_backtrace([])
59
+ Ruby21Error.raise_error('bingo')
60
+ end
61
+ rescue Ruby21Error => ex1
62
+ ex1.set_backtrace([])
63
+ nested_exception = described_class.new(ex1)
64
+ exceptions = nested_exception.as_json
65
+
66
+ expect(exceptions.size).to eq(2)
67
+ expect(exceptions[0][:backtrace]).to be_empty
68
+ expect(exceptions[1][:backtrace]).to be_empty
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,356 @@
1
+ RSpec.describe Airbrake::NoticeNotifier do
2
+ before do
3
+ Airbrake::Config.instance = Airbrake::Config.new(
4
+ project_id: 1,
5
+ project_key: 'abc',
6
+ logger: Logger.new('/dev/null'),
7
+ performance_stats: true
8
+ )
9
+ end
10
+
11
+ describe "#new" do
12
+ describe "default filter addition" do
13
+ before { allow_any_instance_of(Airbrake::FilterChain).to receive(:add_filter) }
14
+
15
+ it "appends the context filter" do
16
+ expect_any_instance_of(Airbrake::FilterChain).to receive(:add_filter)
17
+ .with(instance_of(Airbrake::Filters::ContextFilter))
18
+ subject
19
+ end
20
+
21
+ it "appends the exception attributes filter" do
22
+ expect_any_instance_of(Airbrake::FilterChain).to receive(:add_filter)
23
+ .with(instance_of(Airbrake::Filters::ExceptionAttributesFilter))
24
+ subject
25
+ end
26
+ end
27
+ end
28
+
29
+ describe "#notify" do
30
+ let(:endpoint) { 'https://api.airbrake.io/api/v3/projects/1/notices' }
31
+
32
+ let(:body) do
33
+ {
34
+ 'id' => '00054414-b147-6ffa-85d6-1524d83362a6',
35
+ 'url' => 'http://localhost/locate/00054414-b147-6ffa-85d6-1524d83362a6'
36
+ }.to_json
37
+ end
38
+
39
+ before { stub_request(:post, endpoint).to_return(status: 201, body: body) }
40
+
41
+ it "returns a promise" do
42
+ expect(subject.notify('ex')).to be_an(Airbrake::Promise)
43
+ sleep 1
44
+ end
45
+
46
+ it "refines the notice object" do
47
+ subject.add_filter { |n| n[:params] = { foo: 'bar' } }
48
+ notice = subject.build_notice('ex')
49
+ subject.notify(notice)
50
+ expect(notice[:params]).to eq(foo: 'bar')
51
+ sleep 1
52
+ end
53
+
54
+ context "when config is invalid" do
55
+ before { Airbrake::Config.instance.merge(project_id: nil) }
56
+
57
+ it "returns a rejected promise" do
58
+ promise = subject.notify({})
59
+ expect(promise).to be_rejected
60
+ end
61
+ end
62
+
63
+ context "when a notice is not ignored" do
64
+ it "yields the notice" do
65
+ expect { |b| subject.notify('ex', &b) }
66
+ .to yield_with_args(Airbrake::Notice)
67
+ sleep 1
68
+ end
69
+ end
70
+
71
+ context "when a notice is ignored via a filter" do
72
+ before { subject.add_filter(&:ignore!) }
73
+
74
+ it "yields the notice" do
75
+ expect { |b| subject.notify('ex', &b) }
76
+ .to yield_with_args(Airbrake::Notice)
77
+ end
78
+
79
+ it "returns a rejected promise" do
80
+ value = subject.notify('ex').value
81
+ expect(value['error']).to match(/was marked as ignored/)
82
+ end
83
+ end
84
+
85
+ context "when a notice is ignored via an inline filter" do
86
+ before { subject.add_filter { raise AirbrakeTestError } }
87
+
88
+ it "doesn't invoke regular filters" do
89
+ expect { subject.notify('ex', &:ignore!) }.not_to raise_error
90
+ end
91
+ end
92
+
93
+ context "when async sender has workers" do
94
+ it "sends an exception asynchronously" do
95
+ expect_any_instance_of(Airbrake::AsyncSender).to receive(:send)
96
+ subject.notify('foo', bingo: 'bango')
97
+ end
98
+ end
99
+
100
+ context "when async sender doesn't have workers" do
101
+ it "sends an exception synchronously" do
102
+ expect_any_instance_of(Airbrake::AsyncSender)
103
+ .to receive(:has_workers?).and_return(false)
104
+ expect_any_instance_of(Airbrake::SyncSender).to receive(:send)
105
+ subject.notify('foo', bingo: 'bango')
106
+ end
107
+ end
108
+
109
+ context "when the provided environment is ignored" do
110
+ before do
111
+ Airbrake::Config.instance.merge(
112
+ environment: 'test',
113
+ ignore_environments: %w[test]
114
+ )
115
+ end
116
+
117
+ it "doesn't send an notice" do
118
+ expect_any_instance_of(Airbrake::AsyncSender).not_to receive(:send)
119
+ subject.notify('foo', bingo: 'bango')
120
+ end
121
+
122
+ it "returns a rejected promise" do
123
+ promise = subject.notify('foo', bingo: 'bango')
124
+ expect(promise.value).to eq('error' => "current environment 'test' is ignored")
125
+ end
126
+ end
127
+ end
128
+
129
+ describe "#notify_sync" do
130
+ let(:endpoint) { 'https://api.airbrake.io/api/v3/projects/1/notices' }
131
+
132
+ let(:body) do
133
+ {
134
+ 'id' => '00054414-b147-6ffa-85d6-1524d83362a6',
135
+ 'url' => 'http://localhost/locate/00054414-b147-6ffa-85d6-1524d83362a6'
136
+ }
137
+ end
138
+
139
+ before { stub_request(:post, endpoint).to_return(status: 201, body: body.to_json) }
140
+
141
+ it "returns a reponse hash" do
142
+ expect(subject.notify_sync('ex')).to eq(body)
143
+ end
144
+
145
+ it "refines the notice object" do
146
+ subject.add_filter { |n| n[:params] = { foo: 'bar' } }
147
+ notice = subject.build_notice('ex')
148
+ subject.notify_sync(notice)
149
+ expect(notice[:params]).to eq(foo: 'bar')
150
+ end
151
+
152
+ it "sends an exception synchronously" do
153
+ subject.notify_sync('foo', bingo: 'bango')
154
+ expect(
155
+ a_request(:post, endpoint).with(
156
+ body: /"params":{.*"bingo":"bango".*}/
157
+ )
158
+ ).to have_been_made.once
159
+ end
160
+
161
+ context "when a notice is not ignored" do
162
+ it "yields the notice" do
163
+ expect { |b| subject.notify_sync('ex', &b) }
164
+ .to yield_with_args(Airbrake::Notice)
165
+ end
166
+ end
167
+
168
+ context "when a notice is ignored via a filter" do
169
+ before { subject.add_filter(&:ignore!) }
170
+
171
+ it "yields the notice" do
172
+ expect { |b| subject.notify_sync('ex', &b) }
173
+ .to yield_with_args(Airbrake::Notice)
174
+ end
175
+
176
+ it "returns an error hash" do
177
+ response = subject.notify_sync('ex')
178
+ expect(response['error']).to match(/was marked as ignored/)
179
+ end
180
+ end
181
+
182
+ context "when a notice is ignored via an inline filter" do
183
+ before { subject.add_filter { raise AirbrakeTestError } }
184
+
185
+ it "doesn't invoke regular filters" do
186
+ expect { subject.notify('ex', &:ignore!) }.not_to raise_error
187
+ end
188
+ end
189
+
190
+ context "when the provided environment is ignored" do
191
+ before do
192
+ Airbrake::Config.instance.merge(
193
+ environment: 'test', ignore_environments: %w[test]
194
+ )
195
+ end
196
+
197
+ it "doesn't send an notice" do
198
+ expect_any_instance_of(Airbrake::SyncSender).not_to receive(:send)
199
+ subject.notify_sync('foo', bingo: 'bango')
200
+ end
201
+
202
+ it "returns an error hash" do
203
+ expect(subject.notify_sync('foo'))
204
+ .to eq('error' => "current environment 'test' is ignored")
205
+ end
206
+ end
207
+ end
208
+
209
+ describe "#add_filter" do
210
+ context "given a block" do
211
+ it "appends a new filter to the filter chain" do
212
+ notifier = subject
213
+ b = proc {}
214
+ expect_any_instance_of(Airbrake::FilterChain)
215
+ .to receive(:add_filter) { |*args| expect(args.last).to be(b) }
216
+ notifier.add_filter(&b)
217
+ end
218
+ end
219
+
220
+ context "given a class" do
221
+ it "appends a new filter to the filter chain" do
222
+ notifier = subject
223
+ klass = Class.new
224
+ expect_any_instance_of(Airbrake::FilterChain)
225
+ .to receive(:add_filter).with(klass)
226
+ notifier.add_filter(klass)
227
+ end
228
+ end
229
+ end
230
+
231
+ describe "#build_notice" do
232
+ context "when given exception is another notice" do
233
+ it "merges params with the notice" do
234
+ notice = subject.build_notice('ex')
235
+ other = subject.build_notice(notice, foo: 'bar')
236
+ expect(other[:params]).to eq(foo: 'bar')
237
+ end
238
+
239
+ it "it returns the provided notice" do
240
+ notice = subject.build_notice('ex')
241
+ other = subject.build_notice(notice, foo: 'bar')
242
+ expect(other).to eq(notice)
243
+ end
244
+ end
245
+
246
+ context "when given exception is an Exception" do
247
+ it "prevents mutation of passed-in params hash" do
248
+ params = { immutable: true }
249
+ notice = subject.build_notice('ex', params)
250
+ notice[:params][:mutable] = true
251
+ expect(params).to eq(immutable: true)
252
+ end
253
+
254
+ context "and also when it doesn't have own backtrace" do
255
+ context "and when the generated backtrace consists only of library frames" do
256
+ it "returns the full generated backtrace" do
257
+ backtrace = [
258
+ "/lib/airbrake-ruby/a.rb:84:in `build_notice'",
259
+ "/lib/airbrake-ruby/b.rb:124:in `send_notice'"
260
+ ]
261
+ allow(Kernel).to receive(:caller).and_return(backtrace)
262
+
263
+ notice = subject.build_notice(Exception.new)
264
+
265
+ expect(notice[:errors].first[:backtrace]).to eq(
266
+ [
267
+ { file: '/lib/airbrake-ruby/a.rb', line: 84, function: 'build_notice' },
268
+ { file: '/lib/airbrake-ruby/b.rb', line: 124, function: 'send_notice' }
269
+ ]
270
+ )
271
+ end
272
+ end
273
+
274
+ context "and when the generated backtrace consists of mixed frames" do
275
+ it "returns the filtered backtrace" do
276
+ backtrace = [
277
+ "/airbrake-ruby/lib/airbrake-ruby/a.rb:84:in `b'",
278
+ "/airbrake-ruby/lib/foo/b.rb:84:in `build'",
279
+ "/airbrake-ruby/lib/bar/c.rb:124:in `send'"
280
+ ]
281
+ allow(Kernel).to receive(:caller).and_return(backtrace)
282
+
283
+ notice = subject.build_notice(Exception.new)
284
+
285
+ expect(notice[:errors].first[:backtrace]).to eq(
286
+ [
287
+ { file: '/airbrake-ruby/lib/foo/b.rb', line: 84, function: 'build' },
288
+ { file: '/airbrake-ruby/lib/bar/c.rb', line: 124, function: 'send' }
289
+ ]
290
+ )
291
+ end
292
+ end
293
+ end
294
+ end
295
+
296
+ # TODO: this seems to be bugged. Fix later.
297
+ context "when given exception is a Java exception", skip: true do
298
+ before do
299
+ expect(Airbrake::Backtrace).to receive(:java_exception?).and_return(true)
300
+ end
301
+
302
+ it "automatically generates the backtrace" do
303
+ backtrace = [
304
+ "org/jruby/RubyKernel.java:998:in `eval'",
305
+ "/ruby/stdlib/irb/workspace.rb:87:in `evaluate'",
306
+ "/ruby/stdlib/irb.rb:489:in `block in eval_input'"
307
+ ]
308
+ allow(Kernel).to receive(:caller).and_return(backtrace)
309
+
310
+ notice = subject.build_notice(Exception.new)
311
+
312
+ # rubocop:disable Metrics/LineLength
313
+ expect(notice[:errors].first[:backtrace]).to eq(
314
+ [
315
+ { file: 'org/jruby/RubyKernel.java', line: 998, function: 'eval' },
316
+ { file: '/ruby/stdlib/irb/workspace.rb', line: 87, function: 'evaluate' },
317
+ { file: '/ruby/stdlib/irb.rb:489', line: 489, function: 'block in eval_input' }
318
+ ]
319
+ )
320
+ # rubocop:enable Metrics/LineLength
321
+ end
322
+ end
323
+
324
+ context "when async sender is closed" do
325
+ before do
326
+ expect_any_instance_of(Airbrake::AsyncSender)
327
+ .to receive(:closed?).and_return(true)
328
+ end
329
+
330
+ it "raises error" do
331
+ expect { subject.build_notice(Exception.new) }.to raise_error(
332
+ Airbrake::Error,
333
+ 'attempted to build Exception with closed Airbrake instance'
334
+ )
335
+ end
336
+ end
337
+ end
338
+
339
+ describe "#close" do
340
+ it "sends the close message to async sender" do
341
+ expect_any_instance_of(Airbrake::AsyncSender).to receive(:close)
342
+ subject.close
343
+ end
344
+ end
345
+
346
+ describe "#configured?" do
347
+ it { is_expected.to be_configured }
348
+ end
349
+
350
+ describe "#merge_context" do
351
+ it "merges the provided context with the notice object" do
352
+ expect_any_instance_of(Hash).to receive(:merge!).with(apples: 'oranges')
353
+ subject.merge_context(apples: 'oranges')
354
+ end
355
+ end
356
+ end