bugsnag 6.14.0 → 6.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/VERSION +1 -1
  4. data/features/fixtures/docker-compose.yml +5 -1
  5. data/features/fixtures/plain/app/report_modification/initiators/handled_on_error.rb +10 -0
  6. data/features/fixtures/plain/app/report_modification/initiators/unhandled_on_error.rb +11 -0
  7. data/features/fixtures/plain/app/stack_frame_modification/initiators/handled_on_error.rb +29 -0
  8. data/features/fixtures/plain/app/stack_frame_modification/initiators/unhandled_on_error.rb +26 -0
  9. data/features/fixtures/rails3/app/config/initializers/bugsnag.rb +8 -0
  10. data/features/fixtures/rails4/app/config/initializers/bugsnag.rb +8 -0
  11. data/features/fixtures/rails5/app/config/initializers/bugsnag.rb +8 -0
  12. data/features/fixtures/rails6/app/config/initializers/bugsnag.rb +8 -0
  13. data/features/plain_features/add_tab.feature +7 -1
  14. data/features/plain_features/ignore_report.feature +2 -0
  15. data/features/plain_features/report_api_key.feature +3 -1
  16. data/features/plain_features/report_severity.feature +2 -0
  17. data/features/plain_features/report_stack_frames.feature +4 -0
  18. data/features/plain_features/report_user.feature +7 -1
  19. data/features/rails_features/on_error.feature +29 -0
  20. data/lib/bugsnag.rb +35 -0
  21. data/lib/bugsnag/code_extractor.rb +137 -0
  22. data/lib/bugsnag/configuration.rb +27 -0
  23. data/lib/bugsnag/middleware_stack.rb +38 -3
  24. data/lib/bugsnag/on_error_callbacks.rb +33 -0
  25. data/lib/bugsnag/report.rb +1 -1
  26. data/lib/bugsnag/session_tracker.rb +3 -3
  27. data/lib/bugsnag/stacktrace.rb +25 -68
  28. data/spec/code_extractor_spec.rb +129 -0
  29. data/spec/fixtures/crashes/file1.rb +29 -0
  30. data/spec/fixtures/crashes/file2.rb +25 -0
  31. data/spec/fixtures/crashes/file_with_long_lines.rb +7 -0
  32. data/spec/fixtures/crashes/functions.rb +29 -0
  33. data/spec/fixtures/crashes/short_file.rb +2 -0
  34. data/spec/on_error_spec.rb +332 -0
  35. data/spec/report_spec.rb +7 -4
  36. data/spec/spec_helper.rb +8 -0
  37. data/spec/stacktrace_spec.rb +276 -30
  38. metadata +15 -2
@@ -318,6 +318,33 @@ module Bugsnag
318
318
  @enable_sessions = false
319
319
  end
320
320
 
321
+ ##
322
+ # Add the given callback to the list of on_error callbacks
323
+ #
324
+ # The on_error callbacks will be called when an error is captured or reported
325
+ # and are passed a {Bugsnag::Report} object
326
+ #
327
+ # Returning false from an on_error callback will cause the error to be ignored
328
+ # and will prevent any remaining callbacks from being called
329
+ #
330
+ # @param callback [Proc]
331
+ # @return [void]
332
+ def add_on_error(callback)
333
+ middleware.use(callback)
334
+ end
335
+
336
+ ##
337
+ # Remove the given callback from the list of on_error callbacks
338
+ #
339
+ # Note that this must be the same Proc instance that was passed to
340
+ # {#add_on_error}, otherwise it will not be removed
341
+ #
342
+ # @param callback [Proc]
343
+ # @return [void]
344
+ def remove_on_error(callback)
345
+ middleware.remove(callback)
346
+ end
347
+
321
348
  private
322
349
 
323
350
  attr_writer :scopes_to_filter
@@ -1,3 +1,5 @@
1
+ require "bugsnag/on_error_callbacks"
2
+
1
3
  module Bugsnag
2
4
  class MiddlewareStack
3
5
  ##
@@ -65,11 +67,26 @@ module Bugsnag
65
67
  end
66
68
  end
67
69
 
70
+ ##
71
+ # Disable the given middleware. This removes them from the list of
72
+ # middleware and ensures they cannot be added again
73
+ #
74
+ # See also {#remove}
68
75
  def disable(*middlewares)
69
76
  @mutex.synchronize do
70
77
  @disabled_middleware += middlewares
71
78
 
72
- @middlewares.delete_if {|m| @disabled_middleware.include?(m)}
79
+ @middlewares.delete_if {|m| @disabled_middleware.include?(m) }
80
+ end
81
+ end
82
+
83
+ ##
84
+ # Remove the given middleware from the list of middleware
85
+ #
86
+ # This is like {#disable} but allows the middleware to be added again
87
+ def remove(*middlewares)
88
+ @mutex.synchronize do
89
+ @middlewares.delete_if {|m| middlewares.include?(m) }
73
90
  end
74
91
  end
75
92
 
@@ -91,7 +108,7 @@ module Bugsnag
91
108
 
92
109
  begin
93
110
  # We reverse them, so we can call "call" on the first middleware
94
- middleware_procs.reverse.inject(notify_lambda) { |n,e| e.call(n) }.call(report)
111
+ middleware_procs.reverse.inject(notify_lambda) {|n, e| e.call(n) }.call(report)
95
112
  rescue StandardError => e
96
113
  # KLUDGE: Since we don't re-raise middleware exceptions, this breaks rspec
97
114
  raise if e.class.to_s == "RSpec::Expectations::ExpectationNotMetError"
@@ -107,10 +124,28 @@ module Bugsnag
107
124
  end
108
125
 
109
126
  private
127
+
128
+ ##
110
129
  # Generates a list of middleware procs that are ready to be run
111
130
  # Pass each one a reference to the next in the queue
131
+ #
132
+ # @return [Array<Proc>]
112
133
  def middleware_procs
113
- @middlewares.map{|middleware| proc { |next_middleware| middleware.new(next_middleware) } }
134
+ # Split the middleware into separate lists of Procs and Classes
135
+ procs, classes = @middlewares.partition {|middleware| middleware.is_a?(Proc) }
136
+
137
+ # Wrap the classes in a proc that, when called, news up the middleware and
138
+ # passes the next middleware in the queue
139
+ middleware_instances = classes.map do |middleware|
140
+ proc {|next_middleware| middleware.new(next_middleware) }
141
+ end
142
+
143
+ # Wrap the list of procs in a proc that, when called, wraps them in an
144
+ # 'OnErrorCallbacks' instance that also has a reference to the next middleware
145
+ wrapped_procs = proc {|next_middleware| OnErrorCallbacks.new(next_middleware, procs) }
146
+
147
+ # Return the combined middleware and wrapped procs
148
+ middleware_instances.push(wrapped_procs)
114
149
  end
115
150
  end
116
151
  end
@@ -0,0 +1,33 @@
1
+ module Bugsnag
2
+ # @api private
3
+ class OnErrorCallbacks
4
+ def initialize(next_middleware, callbacks)
5
+ @next_middleware = next_middleware
6
+ @callbacks = callbacks
7
+ end
8
+
9
+ ##
10
+ # @param report [Report]
11
+ def call(report)
12
+ @callbacks.each do |callback|
13
+ begin
14
+ should_continue = callback.call(report)
15
+ rescue StandardError => e
16
+ Bugsnag.configuration.warn("Error occurred in on_error callback: '#{e}'")
17
+ Bugsnag.configuration.warn("on_error callback stacktrace: #{e.backtrace.inspect}")
18
+ end
19
+
20
+ # If a callback returns false, we ignore the report and stop running callbacks
21
+ # Note that we explicitly check for 'false' so that callbacks don't need
22
+ # to return anything (i.e. can return 'nil') and we still continue
23
+ next unless should_continue == false
24
+
25
+ report.ignore!
26
+
27
+ break
28
+ end
29
+
30
+ @next_middleware.call(report)
31
+ end
32
+ end
33
+ end
@@ -182,7 +182,7 @@ module Bugsnag
182
182
  {
183
183
  errorClass: error_class(exception),
184
184
  message: exception.message,
185
- stacktrace: Stacktrace.new(exception.backtrace, configuration).to_a
185
+ stacktrace: Stacktrace.process(exception.backtrace, configuration)
186
186
  }
187
187
  end
188
188
  end
@@ -1,11 +1,9 @@
1
1
  require 'thread'
2
2
  require 'time'
3
3
  require 'securerandom'
4
- require 'concurrent'
5
4
 
6
5
  module Bugsnag
7
6
  class SessionTracker
8
-
9
7
  THREAD_SESSION = "bugsnag_session"
10
8
  SESSION_PAYLOAD_VERSION = "1.0"
11
9
  MUTEX = Mutex.new
@@ -27,6 +25,8 @@ module Bugsnag
27
25
  ##
28
26
  # Initializes the session tracker.
29
27
  def initialize
28
+ require 'concurrent'
29
+
30
30
  @session_counts = Concurrent::Hash.new(0)
31
31
  end
32
32
 
@@ -139,4 +139,4 @@ module Bugsnag
139
139
  Bugsnag::Delivery[Bugsnag.configuration.delivery_method].deliver(Bugsnag.configuration.session_endpoint, payload, Bugsnag.configuration, options)
140
140
  end
141
141
  end
142
- end
142
+ end
@@ -1,6 +1,7 @@
1
- module Bugsnag
2
- class Stacktrace
1
+ require_relative 'code_extractor'
3
2
 
3
+ module Bugsnag
4
+ module Stacktrace
4
5
  # e.g. "org/jruby/RubyKernel.java:1264:in `catch'"
5
6
  BACKTRACE_LINE_REGEX = /^((?:[a-zA-Z]:)?[^:]+):(\d+)(?::in `([^']+)')?$/
6
7
 
@@ -10,13 +11,15 @@ module Bugsnag
10
11
  ##
11
12
  # Process a backtrace and the configuration into a parsed stacktrace.
12
13
  #
13
- # rubocop:todo Metrics/CyclomaticComplexity
14
- def initialize(backtrace, configuration)
15
- @configuration = configuration
14
+ # @param backtrace [Array, nil] If nil, 'caller' will be used instead
15
+ # @param configuration [Configuration]
16
+ # @return [Array]
17
+ def self.process(backtrace, configuration)
18
+ code_extractor = CodeExtractor.new(configuration)
16
19
 
17
20
  backtrace = caller if !backtrace || backtrace.empty?
18
21
 
19
- @processed_backtrace = backtrace.map do |trace|
22
+ processed_backtrace = backtrace.map do |trace|
20
23
  # Parse the stacktrace line
21
24
  if trace.match(BACKTRACE_LINE_REGEX)
22
25
  file, line_str, method = [$1, $2, $3]
@@ -24,27 +27,25 @@ module Bugsnag
24
27
  method, file, line_str = [$1, $2, $3]
25
28
  end
26
29
 
27
- next(nil) if file.nil?
30
+ next if file.nil?
28
31
 
29
32
  # Expand relative paths
30
33
  file = File.realpath(file) rescue file
31
34
 
32
35
  # Generate the stacktrace line hash
33
- trace_hash = {}
34
- trace_hash[:lineNumber] = line_str.to_i
36
+ trace_hash = { lineNumber: line_str.to_i }
35
37
 
36
- if configuration.send_code
37
- trace_hash[:code] = code(file, trace_hash[:lineNumber])
38
- end
38
+ # Save a copy of the file path as we're about to modify it but need the
39
+ # raw version when extracting code (otherwise we can't open the file)
40
+ raw_file_path = file.dup
39
41
 
40
42
  # Clean up the file path in the stacktrace
41
- if defined?(@configuration.project_root) && @configuration.project_root.to_s != ''
42
- trace_hash[:inProject] = true if file.start_with?(@configuration.project_root.to_s)
43
- file.sub!(/#{@configuration.project_root}\//, "")
44
- trace_hash.delete(:inProject) if file.match(@configuration.vendor_path)
43
+ if defined?(configuration.project_root) && configuration.project_root.to_s != ''
44
+ trace_hash[:inProject] = true if file.start_with?(configuration.project_root.to_s)
45
+ file.sub!(/#{configuration.project_root}\//, "")
46
+ trace_hash.delete(:inProject) if file.match(configuration.vendor_path)
45
47
  end
46
48
 
47
-
48
49
  # Strip common gem path prefixes
49
50
  if defined?(Gem)
50
51
  file = Gem.path.inject(file) {|line, path| line.sub(/#{path}\//, "") }
@@ -55,60 +56,16 @@ module Bugsnag
55
56
  # Add a method if we have it
56
57
  trace_hash[:method] = method if method && (method =~ /^__bind/).nil?
57
58
 
58
- if trace_hash[:file] && !trace_hash[:file].empty?
59
- trace_hash
60
- else
61
- nil
62
- end
63
- end.compact
64
- end
65
- # rubocop:enable Metrics/CyclomaticComplexity
66
-
67
- ##
68
- # Returns the processed backtrace
69
- def to_a
70
- @processed_backtrace
71
- end
72
-
73
- private
59
+ # If we're going to send code then record the raw file path and the
60
+ # trace_hash, so we can extract from it later
61
+ code_extractor.add_file(raw_file_path, trace_hash) if configuration.send_code
74
62
 
75
- def code(file, line_number, num_lines = 7)
76
- code_hash = {}
77
-
78
- from_line = [line_number - num_lines, 1].max
79
-
80
- # don't try and open '(irb)' or '-e'
81
- return unless File.exist?(file)
82
-
83
- # Populate code hash with line numbers and code lines
84
- File.open(file) do |f|
85
- current_line_number = 0
86
- f.each_line do |line|
87
- current_line_number += 1
88
-
89
- next if current_line_number < from_line
90
-
91
- code_hash[current_line_number] = line[0...200].rstrip
92
-
93
- break if code_hash.length >= ( num_lines * 1.5 ).ceil
94
- end
95
- end
96
-
97
- while code_hash.length > num_lines
98
- last_line = code_hash.keys.max
99
- first_line = code_hash.keys.min
63
+ trace_hash
64
+ end.compact
100
65
 
101
- if (last_line - line_number) > (line_number - first_line)
102
- code_hash.delete(last_line)
103
- else
104
- code_hash.delete(first_line)
105
- end
106
- end
66
+ code_extractor.extract! if configuration.send_code
107
67
 
108
- code_hash
109
- rescue
110
- @configuration.warn("Error fetching code: #{$!.inspect}")
111
- nil
68
+ processed_backtrace
112
69
  end
113
70
  end
114
71
  end
@@ -0,0 +1,129 @@
1
+ require 'spec_helper'
2
+
3
+ describe Bugsnag::CodeExtractor do
4
+ it "extracts code from a file and adds it to the given hash" do
5
+ file1_hash = { lineNumber: 5 }
6
+ file2_hash = { lineNumber: 7 }
7
+
8
+ code_extractor = Bugsnag::CodeExtractor.new(Bugsnag::Configuration.new)
9
+ code_extractor.add_file("spec/fixtures/crashes/file1.rb", file1_hash)
10
+ code_extractor.add_file("spec/fixtures/crashes/file2.rb", file2_hash)
11
+
12
+ code_extractor.extract!
13
+
14
+ expect(file1_hash).to eq({
15
+ lineNumber: 5,
16
+ code: {
17
+ 2 => "",
18
+ 3 => "module File1",
19
+ 4 => " def self.foo1",
20
+ 5 => " File2.foo2",
21
+ 6 => " end",
22
+ 7 => "",
23
+ 8 => " def self.bar1"
24
+ }
25
+ })
26
+
27
+ expect(file2_hash).to eq({
28
+ lineNumber: 7,
29
+ code: {
30
+ 4 => " end",
31
+ 5 => "",
32
+ 6 => " def self.bar2",
33
+ 7 => " File1.baz1",
34
+ 8 => " end",
35
+ 9 => "",
36
+ 10 => " def self.baz2"
37
+ }
38
+ })
39
+ end
40
+
41
+ it "handles extracting code from the first & last line in a file" do
42
+ file1_hash = { lineNumber: 1 }
43
+ file2_hash = { lineNumber: 25 }
44
+
45
+ code_extractor = Bugsnag::CodeExtractor.new(Bugsnag::Configuration.new)
46
+ code_extractor.add_file("spec/fixtures/crashes/file1.rb", file1_hash)
47
+ code_extractor.add_file("spec/fixtures/crashes/file2.rb", file2_hash)
48
+
49
+ code_extractor.extract!
50
+
51
+ expect(file1_hash).to eq({
52
+ lineNumber: 1,
53
+ code: {
54
+ 1 => "require_relative 'file2'",
55
+ 2 => "",
56
+ 3 => "module File1",
57
+ 4 => " def self.foo1",
58
+ 5 => " File2.foo2",
59
+ 6 => " end",
60
+ 7 => ""
61
+ }
62
+ })
63
+
64
+ expect(file2_hash).to eq({
65
+ lineNumber: 25,
66
+ code: {
67
+ 19 => " puts 'abcdef2'",
68
+ 20 => " end",
69
+ 21 => "",
70
+ 22 => " def self.abcdefghi2",
71
+ 23 => " puts 'abcdefghi2'",
72
+ 24 => " end",
73
+ 25 => "end"
74
+ }
75
+ })
76
+ end
77
+
78
+ it "truncates lines to a maximum of 200 characters" do
79
+ hash = { lineNumber: 4 }
80
+
81
+ code_extractor = Bugsnag::CodeExtractor.new(Bugsnag::Configuration.new)
82
+ code_extractor.add_file("spec/fixtures/crashes/file_with_long_lines.rb", hash)
83
+
84
+ code_extractor.extract!
85
+
86
+ # rubocop:disable Layout/LineLength
87
+ expect(hash).to eq({
88
+ lineNumber: 4,
89
+ code: {
90
+ 1 => "# rubocop:disable Layout/LineLength",
91
+ 2 => "def a_super_long_function_name_that_would_be_really_impractical_to_use_but_luckily_this_is_just_for_a_test_to_prove_we_can_handle_really_long_lines_of_code_that_go_over_200_characters_and_some_more_pa",
92
+ 3 => " puts 'This is a shorter string'",
93
+ 4 => " puts 'A more realistic example of when a line would be really long is long strings such as this one, which extends over the 200 character limit by containing a lot of excess words for padding its le",
94
+ 5 => " puts 'and another shorter string for comparison'",
95
+ 6 => "end",
96
+ 7 => "# rubocop:enable Layout/LineLength",
97
+ }
98
+ })
99
+ # rubocop:enable Layout/LineLength
100
+ end
101
+
102
+ it "rescues exceptions raised in extract!" do
103
+ file1_hash = { lineNumber: 1 }
104
+ file2_hash = { lineNumber: 25 }
105
+
106
+ code_extractor = Bugsnag::CodeExtractor.new(Bugsnag::Configuration.new)
107
+ code_extractor.add_file("spec/fixtures/crashes/file1.rb", file1_hash)
108
+ code_extractor.add_file("spec/fixtures/crashes/file2.rb", file2_hash)
109
+
110
+ file1_hash[:first_line_number] = nil
111
+
112
+ code_extractor.extract!
113
+
114
+ expect(file1_hash).to eq({ lineNumber: 1, code: nil })
115
+
116
+ expect(file2_hash).to eq({
117
+ lineNumber: 25,
118
+ code: {
119
+ 19 => " puts 'abcdef2'",
120
+ 20 => " end",
121
+ 21 => "",
122
+ 22 => " def self.abcdefghi2",
123
+ 23 => " puts 'abcdefghi2'",
124
+ 24 => " end",
125
+ 25 => "end"
126
+ }
127
+ })
128
+ end
129
+ end
@@ -0,0 +1,29 @@
1
+ require_relative 'file2'
2
+
3
+ module File1
4
+ def self.foo1
5
+ File2.foo2
6
+ end
7
+
8
+ def self.bar1
9
+ File2.bar2
10
+ end
11
+
12
+ def self.baz1
13
+ File2.baz2
14
+ end
15
+
16
+ def self.abc1
17
+ puts 'abc'
18
+ end
19
+
20
+ def self.abcdef1
21
+ puts 'abcdef1'
22
+ end
23
+
24
+ def self.abcdefghi1
25
+ puts 'abcdefghi1'
26
+ end
27
+ end
28
+
29
+ File1.foo1