bugsnag 6.19.0 → 6.23.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -9,11 +9,44 @@ require "bugsnag/integrations/rails/rails_breadcrumbs"
9
9
 
10
10
  module Bugsnag
11
11
  class Railtie < ::Rails::Railtie
12
-
13
12
  FRAMEWORK_ATTRIBUTES = {
14
13
  :framework => "Rails"
15
14
  }
16
15
 
16
+ ##
17
+ # Subscribes to an ActiveSupport event, leaving a breadcrumb when it triggers
18
+ #
19
+ # @api private
20
+ # @param event [Hash] details of the event to subscribe to
21
+ def event_subscription(event)
22
+ ActiveSupport::Notifications.subscribe(event[:id]) do |*, event_id, data|
23
+ filtered_data = data.slice(*event[:allowed_data])
24
+ filtered_data[:event_name] = event[:id]
25
+ filtered_data[:event_id] = event_id
26
+
27
+ if event[:id] == "sql.active_record"
28
+ if data.key?(:binds)
29
+ binds = data[:binds].each_with_object({}) { |bind, output| output[bind.name] = '?' if defined?(bind.name) }
30
+ filtered_data[:binds] = JSON.dump(binds) unless binds.empty?
31
+ end
32
+
33
+ # Rails < 6.1 included connection_id in the event data, but now
34
+ # includes the connection object instead
35
+ if data.key?(:connection) && !data.key?(:connection_id)
36
+ # the connection ID is the object_id of the connection object
37
+ filtered_data[:connection_id] = data[:connection].object_id
38
+ end
39
+ end
40
+
41
+ Bugsnag.leave_breadcrumb(
42
+ event[:message],
43
+ filtered_data,
44
+ event[:type],
45
+ :auto
46
+ )
47
+ end
48
+ end
49
+
17
50
  rake_tasks do
18
51
  require "bugsnag/integrations/rake"
19
52
  load "bugsnag/tasks/bugsnag.rake"
@@ -27,7 +60,7 @@ module Bugsnag
27
60
  config.logger = ::Rails.logger
28
61
  config.release_stage ||= ::Rails.env.to_s
29
62
  config.project_root = ::Rails.root.to_s
30
- config.middleware.insert_before Bugsnag::Middleware::Callbacks, Bugsnag::Middleware::Rails3Request
63
+ config.internal_middleware.use(Bugsnag::Middleware::Rails3Request)
31
64
  config.runtime_versions["rails"] = ::Rails::VERSION::STRING
32
65
  end
33
66
 
@@ -41,6 +74,14 @@ module Bugsnag
41
74
  include Bugsnag::Rails::ActiveRecordRescue
42
75
  end
43
76
 
77
+ ActiveSupport.on_load(:active_job) do
78
+ require "bugsnag/middleware/active_job"
79
+ Bugsnag.configuration.internal_middleware.use(Bugsnag::Middleware::ActiveJob)
80
+
81
+ require "bugsnag/integrations/rails/active_job"
82
+ include Bugsnag::Rails::ActiveJob
83
+ end
84
+
44
85
  Bugsnag::Rails::DEFAULT_RAILS_BREADCRUMBS.each { |event| event_subscription(event) }
45
86
 
46
87
  # Make sure we don't overwrite the value set by another integration because
@@ -80,28 +121,5 @@ module Bugsnag
80
121
  Bugsnag.configuration.warn("Unable to add Bugsnag::Rack middleware as the middleware stack is frozen")
81
122
  end
82
123
  end
83
-
84
- ##
85
- # Subscribes to an ActiveSupport event, leaving a breadcrumb when it triggers
86
- #
87
- # @api private
88
- # @param event [Hash] details of the event to subscribe to
89
- def event_subscription(event)
90
- ActiveSupport::Notifications.subscribe(event[:id]) do |*, event_id, data|
91
- filtered_data = data.slice(*event[:allowed_data])
92
- filtered_data[:event_name] = event[:id]
93
- filtered_data[:event_id] = event_id
94
- if event[:id] == "sql.active_record" && data.key?(:binds)
95
- binds = data[:binds].each_with_object({}) { |bind, output| output[bind.name] = '?' if defined?(bind.name) }
96
- filtered_data[:binds] = JSON.dump(binds) unless binds.empty?
97
- end
98
- Bugsnag.leave_breadcrumb(
99
- event[:message],
100
- filtered_data,
101
- event[:type],
102
- :auto
103
- )
104
- end
105
- end
106
124
  end
107
125
  end
@@ -44,9 +44,19 @@ module Bugsnag
44
44
  :attributes => FRAMEWORK_ATTRIBUTES
45
45
  }
46
46
 
47
- context = "#{payload['class']}@#{queue}"
48
- report.meta_data.merge!({:context => context, :payload => payload})
49
- report.context = context
47
+ metadata = payload
48
+ class_name = payload['class']
49
+
50
+ # when using Active Job the payload "class" will always be the Resque
51
+ # "JobWrapper", not the actual job class so we need to fix this here
52
+ if metadata['args'] && metadata['args'][0] && metadata['args'][0]['job_class']
53
+ class_name = metadata['args'][0]['job_class']
54
+ metadata['wrapped'] ||= class_name
55
+ end
56
+
57
+ context = "#{class_name}@#{queue}"
58
+ report.meta_data.merge!({ context: context, payload: metadata })
59
+ report.automatic_context = context
50
60
  end
51
61
  end
52
62
  end
@@ -0,0 +1,18 @@
1
+ module Bugsnag::Middleware
2
+ class ActiveJob
3
+ def initialize(bugsnag)
4
+ @bugsnag = bugsnag
5
+ end
6
+
7
+ def call(report)
8
+ data = report.request_data[:active_job]
9
+
10
+ if data
11
+ report.add_tab(:active_job, data)
12
+ report.automatic_context = "#{data[:job_name]}@#{data[:queue]}"
13
+ end
14
+
15
+ @bugsnag.call(report)
16
+ end
17
+ end
18
+ end
@@ -9,6 +9,7 @@ module Bugsnag::Middleware
9
9
  "ActionController::UnknownAction",
10
10
  "ActionController::UnknownFormat",
11
11
  "ActionController::UnknownHttpMethod",
12
+ "ActionDispatch::Http::MimeNegotiation::InvalidType",
12
13
  "ActiveRecord::RecordNotFound",
13
14
  "CGI::Session::CookieStore::TamperedWithCookie",
14
15
  "Mongoid::Errors::DocumentNotFound",
@@ -2,6 +2,11 @@ module Bugsnag::Middleware
2
2
  ##
3
3
  # Attaches delayed_job information to an error report
4
4
  class DelayedJob
5
+ # Active Job's queue adapter sets the "display_name" to this format. This
6
+ # breaks the event context as the ID and arguments are included, which will
7
+ # differ between executions of the same job
8
+ ACTIVE_JOB_DISPLAY_NAME = /^.* \[[0-9a-f-]+\] from DelayedJob\(.*\) with arguments: \[.*\]$/
9
+
5
10
  def initialize(bugsnag)
6
11
  @bugsnag = bugsnag
7
12
  end
@@ -23,8 +28,10 @@ module Bugsnag::Middleware
23
28
  if job.respond_to?(:payload_object)
24
29
  job_data[:active_job] = job.payload_object.job_data if job.payload_object.respond_to?(:job_data)
25
30
  payload_data = construct_job_payload(job.payload_object)
26
- report.context = payload_data[:display_name] if payload_data.include?(:display_name)
27
- report.context ||= payload_data[:class] if payload_data.include?(:class)
31
+
32
+ context = get_context(payload_data, job_data[:active_job])
33
+ report.automatic_context = context unless context.nil?
34
+
28
35
  job_data[:payload] = payload_data
29
36
  end
30
37
 
@@ -70,5 +77,17 @@ module Bugsnag::Middleware
70
77
  end
71
78
  data
72
79
  end
80
+
81
+ private
82
+
83
+ def get_context(payload_data, active_job_data)
84
+ if payload_data.include?(:display_name) && !ACTIVE_JOB_DISPLAY_NAME.match?(payload_data[:display_name])
85
+ payload_data[:display_name]
86
+ elsif active_job_data && active_job_data['job_class'] && active_job_data['queue_name']
87
+ "#{active_job_data['job_class']}@#{active_job_data['queue_name']}"
88
+ elsif payload_data.include?(:class)
89
+ payload_data[:class]
90
+ end
91
+ end
73
92
  end
74
93
  end
@@ -16,6 +16,8 @@ module Bugsnag::Middleware
16
16
 
17
17
  if exception.respond_to?(:bugsnag_context)
18
18
  context = exception.bugsnag_context
19
+ # note: this should set 'context' not 'automatic_context' as it's a
20
+ # user-supplied value
19
21
  report.context = context if context.is_a?(String)
20
22
  end
21
23
 
@@ -18,8 +18,8 @@ module Bugsnag::Middleware
18
18
  client_ip = request.ip.to_s rescue SPOOF
19
19
  session = env["rack.session"]
20
20
 
21
- # Set the context
22
- report.context = "#{request.request_method} #{request.path}"
21
+ # Set the automatic context
22
+ report.automatic_context = "#{request.request_method} #{request.path}"
23
23
 
24
24
  # Set a sensible default for user_id
25
25
  report.user["id"] = request.ip
@@ -15,8 +15,8 @@ module Bugsnag::Middleware
15
15
  client_ip = env["action_dispatch.remote_ip"].to_s rescue SPOOF
16
16
 
17
17
  if params
18
- # Set the context
19
- report.context = "#{params[:controller]}##{params[:action]}"
18
+ # Set the automatic context
19
+ report.automatic_context = "#{params[:controller]}##{params[:action]}"
20
20
 
21
21
  # Augment the request tab
22
22
  report.add_tab(:request, {
@@ -16,7 +16,7 @@ module Bugsnag::Middleware
16
16
  :arguments => task.arg_description
17
17
  })
18
18
 
19
- report.context ||= task.name
19
+ report.automatic_context ||= task.name
20
20
  end
21
21
 
22
22
  @bugsnag.call(report)
@@ -10,7 +10,7 @@ module Bugsnag::Middleware
10
10
  sidekiq = report.request_data[:sidekiq]
11
11
  if sidekiq
12
12
  report.add_tab(:sidekiq, sidekiq)
13
- report.context ||= "#{sidekiq[:msg]['wrapped'] || sidekiq[:msg]['class']}@#{sidekiq[:msg]['queue']}"
13
+ report.automatic_context ||= "#{sidekiq[:msg]['wrapped'] || sidekiq[:msg]['class']}@#{sidekiq[:msg]['queue']}"
14
14
  end
15
15
  @bugsnag.call(report)
16
16
  end
@@ -131,8 +131,8 @@ module Bugsnag
131
131
  #
132
132
  # @return [Array<Proc>]
133
133
  def middleware_procs
134
- # Split the middleware into separate lists of Procs and Classes
135
- procs, classes = @middlewares.partition {|middleware| middleware.is_a?(Proc) }
134
+ # Split the middleware into separate lists of callables (e.g. Proc, Lambda, Method) and Classes
135
+ callables, classes = @middlewares.partition {|middleware| middleware.respond_to?(:call) }
136
136
 
137
137
  # Wrap the classes in a proc that, when called, news up the middleware and
138
138
  # passes the next middleware in the queue
@@ -140,12 +140,12 @@ module Bugsnag
140
140
  proc {|next_middleware| middleware.new(next_middleware) }
141
141
  end
142
142
 
143
- # Wrap the list of procs in a proc that, when called, wraps them in an
143
+ # Wrap the list of callables in a proc that, when called, wraps them in an
144
144
  # 'OnErrorCallbacks' instance that also has a reference to the next middleware
145
- wrapped_procs = proc {|next_middleware| OnErrorCallbacks.new(next_middleware, procs) }
145
+ wrapped_callables = proc {|next_middleware| OnErrorCallbacks.new(next_middleware, callables) }
146
146
 
147
- # Return the combined middleware and wrapped procs
148
- middleware_instances.push(wrapped_procs)
147
+ # Return the combined middleware and wrapped callables
148
+ middleware_instances.push(wrapped_callables)
149
149
  end
150
150
  end
151
151
  end
@@ -1,8 +1,10 @@
1
1
  require "json"
2
2
  require "pathname"
3
+ require "bugsnag/error"
3
4
  require "bugsnag/stacktrace"
4
5
 
5
6
  module Bugsnag
7
+ # rubocop:todo Metrics/ClassLength
6
8
  class Report
7
9
  NOTIFIER_NAME = "Ruby Bugsnag Notifier"
8
10
  NOTIFIER_VERSION = Bugsnag::VERSION
@@ -45,16 +47,13 @@ module Bugsnag
45
47
  # @return [Configuration]
46
48
  attr_accessor :configuration
47
49
 
48
- # Additional context for this report
49
- # @return [String, nil]
50
- attr_accessor :context
51
-
52
50
  # The delivery method that will be used for this report
53
51
  # @see Configuration#delivery_method
54
52
  # @return [Symbol]
55
53
  attr_accessor :delivery_method
56
54
 
57
55
  # The list of exceptions in this report
56
+ # @deprecated Use {#errors} instead
58
57
  # @return [Array<Hash>]
59
58
  attr_accessor :exceptions
60
59
 
@@ -72,10 +71,12 @@ module Bugsnag
72
71
  attr_accessor :grouping_hash
73
72
 
74
73
  # Arbitrary metadata attached to this report
74
+ # @deprecated Use {#metadata} instead
75
75
  # @return [Hash]
76
76
  attr_accessor :meta_data
77
77
 
78
78
  # The raw Exception instances for this report
79
+ # @deprecated Use {#original_error} instead
79
80
  # @see #exceptions
80
81
  # @return [Array<Exception>]
81
82
  attr_accessor :raw_exceptions
@@ -102,21 +103,35 @@ module Bugsnag
102
103
  # @return [Hash]
103
104
  attr_accessor :user
104
105
 
106
+ # A list of errors in this report
107
+ # @return [Array<Error>]
108
+ attr_reader :errors
109
+
110
+ # The Exception instance this report was created for
111
+ # @return [Exception]
112
+ attr_reader :original_error
113
+
105
114
  ##
106
115
  # Initializes a new report from an exception.
107
116
  def initialize(exception, passed_configuration, auto_notify=false)
117
+ # store the creation time for use as device.time
118
+ @created_at = Time.now.utc.iso8601(3)
119
+
108
120
  @should_ignore = false
109
121
  @unhandled = auto_notify
110
122
 
111
123
  self.configuration = passed_configuration
112
124
 
125
+ @original_error = exception
113
126
  self.raw_exceptions = generate_raw_exceptions(exception)
114
127
  self.exceptions = generate_exception_list
128
+ @errors = generate_error_list
115
129
 
116
130
  self.api_key = configuration.api_key
117
131
  self.app_type = configuration.app_type
118
132
  self.app_version = configuration.app_version
119
133
  self.breadcrumbs = []
134
+ self.context = configuration.context if configuration.context_set?
120
135
  self.delivery_method = configuration.delivery_method
121
136
  self.hostname = configuration.hostname
122
137
  self.runtime_versions = configuration.runtime_versions.dup
@@ -125,8 +140,29 @@ module Bugsnag
125
140
  self.severity = auto_notify ? "error" : "warning"
126
141
  self.severity_reason = auto_notify ? {:type => UNHANDLED_EXCEPTION} : {:type => HANDLED_EXCEPTION}
127
142
  self.user = {}
143
+
144
+ @metadata_delegate = Utility::MetadataDelegate.new
128
145
  end
129
146
 
147
+ ##
148
+ # Additional context for this report
149
+ # @!attribute context
150
+ # @return [String, nil]
151
+ def context
152
+ return @context if defined?(@context)
153
+
154
+ @automatic_context
155
+ end
156
+
157
+ attr_writer :context
158
+
159
+ ##
160
+ # Context set automatically by Bugsnag uses this attribute, which prevents
161
+ # it from overwriting the user-supplied context
162
+ # @api private
163
+ # @return [String, nil]
164
+ attr_accessor :automatic_context
165
+
130
166
  ##
131
167
  # Add a new metadata tab to this notification.
132
168
  #
@@ -135,6 +171,8 @@ module Bugsnag
135
171
  # exists, this will be merged with the existing values. If a Hash is not
136
172
  # given, the value will be placed into the 'custom' tab
137
173
  # @return [void]
174
+ #
175
+ # @deprecated Use {#add_metadata} instead
138
176
  def add_tab(name, value)
139
177
  return if name.nil?
140
178
 
@@ -153,6 +191,8 @@ module Bugsnag
153
191
  #
154
192
  # @param name [String]
155
193
  # @return [void]
194
+ #
195
+ # @deprecated Use {#clear_metadata} instead
156
196
  def remove_tab(name)
157
197
  return if name.nil?
158
198
 
@@ -175,7 +215,8 @@ module Bugsnag
175
215
  context: context,
176
216
  device: {
177
217
  hostname: hostname,
178
- runtimeVersions: runtime_versions
218
+ runtimeVersions: runtime_versions,
219
+ time: @created_at
179
220
  },
180
221
  exceptions: exceptions,
181
222
  groupingHash: grouping_hash,
@@ -257,6 +298,82 @@ module Bugsnag
257
298
  end
258
299
  end
259
300
 
301
+ # A Hash containing arbitrary metadata
302
+ # @!attribute metadata
303
+ # @return [Hash]
304
+ def metadata
305
+ @meta_data
306
+ end
307
+
308
+ # @param metadata [Hash]
309
+ # @return [void]
310
+ def metadata=(metadata)
311
+ @meta_data = metadata
312
+ end
313
+
314
+ ##
315
+ # Data from the current HTTP request. May be nil if no data has been recorded
316
+ #
317
+ # @return [Hash, nil]
318
+ def request
319
+ @meta_data[:request]
320
+ end
321
+
322
+ ##
323
+ # Add values to metadata
324
+ #
325
+ # @overload add_metadata(section, data)
326
+ # Merges data into the given section of metadata
327
+ # @param section [String, Symbol]
328
+ # @param data [Hash]
329
+ #
330
+ # @overload add_metadata(section, key, value)
331
+ # Sets key to value in the given section of metadata. If the value is nil
332
+ # the key will be deleted
333
+ # @param section [String, Symbol]
334
+ # @param key [String, Symbol]
335
+ # @param value
336
+ #
337
+ # @return [void]
338
+ def add_metadata(section, key_or_data, *args)
339
+ @metadata_delegate.add_metadata(@meta_data, section, key_or_data, *args)
340
+ end
341
+
342
+ ##
343
+ # Clear values from metadata
344
+ #
345
+ # @overload clear_metadata(section)
346
+ # Clears the given section of metadata
347
+ # @param section [String, Symbol]
348
+ #
349
+ # @overload clear_metadata(section, key)
350
+ # Clears the key in the given section of metadata
351
+ # @param section [String, Symbol]
352
+ # @param key [String, Symbol]
353
+ #
354
+ # @return [void]
355
+ def clear_metadata(section, *args)
356
+ @metadata_delegate.clear_metadata(@meta_data, section, *args)
357
+ end
358
+
359
+ ##
360
+ # Set information about the current user
361
+ #
362
+ # Additional user fields can be added as metadata in a "user" section
363
+ #
364
+ # Setting a field to 'nil' will remove it from the user data
365
+ #
366
+ # @param id [String, nil]
367
+ # @param email [String, nil]
368
+ # @param name [String, nil]
369
+ # @return [void]
370
+ def set_user(id = nil, email = nil, name = nil)
371
+ new_user = { id: id, email: email, name: name }
372
+ new_user.reject! { |key, value| value.nil? }
373
+
374
+ @user = new_user
375
+ end
376
+
260
377
  private
261
378
 
262
379
  def generate_exception_list
@@ -269,6 +386,12 @@ module Bugsnag
269
386
  end
270
387
  end
271
388
 
389
+ def generate_error_list
390
+ exceptions.map do |exception|
391
+ Error.new(exception[:errorClass], exception[:message], exception[:stacktrace])
392
+ end
393
+ end
394
+
272
395
  def error_class(exception)
273
396
  # The "Class" check is for some strange exceptions like Timeout::Error
274
397
  # which throw the error class instead of an instance
@@ -311,4 +434,5 @@ module Bugsnag
311
434
  exceptions
312
435
  end
313
436
  end
437
+ # rubocop:enable Metrics/ClassLength
314
438
  end
@@ -35,7 +35,8 @@ module Bugsnag
35
35
  #
36
36
  # This allows Bugsnag to track error rates for a release.
37
37
  def start_session
38
- return unless Bugsnag.configuration.enable_sessions
38
+ return unless Bugsnag.configuration.enable_sessions && Bugsnag.configuration.should_notify_release_stage?
39
+
39
40
  start_delivery_thread
40
41
  start_time = Time.now().utc().strftime('%Y-%m-%dT%H:%M:00')
41
42
  new_session = {
@@ -83,7 +84,7 @@ module Bugsnag
83
84
  end
84
85
  end
85
86
  end
86
- @delivery_thread = Concurrent::TimerTask.execute(execution_interval: 30) do
87
+ @delivery_thread = Concurrent::TimerTask.execute(execution_interval: 10) do
87
88
  if @session_counts.size > 0
88
89
  send_sessions
89
90
  end
@@ -106,11 +107,6 @@ module Bugsnag
106
107
  return
107
108
  end
108
109
 
109
- if !Bugsnag.configuration.should_notify_release_stage?
110
- Bugsnag.configuration.debug("Not delivering sessions due to notify_release_stages :#{Bugsnag.configuration.notify_release_stages.inspect}")
111
- return
112
- end
113
-
114
110
  body = {
115
111
  :notifier => {
116
112
  :name => Bugsnag::Report::NOTIFIER_NAME,
@@ -7,7 +7,7 @@ namespace :bugsnag do
7
7
  raise RuntimeError.new("Bugsnag test exception")
8
8
  rescue => e
9
9
  Bugsnag.notify(e) do |report|
10
- report.context = "rake#test_exception"
10
+ report.automatic_context = "rake#test_exception"
11
11
  end
12
12
  end
13
13
  end
@@ -0,0 +1,102 @@
1
+ module Bugsnag::Utility
2
+ # @api private
3
+ class MetadataDelegate
4
+ # nil is a valid metadata value, so we need a sentinel object so we can tell
5
+ # if the value parameter has been provided
6
+ NOT_PROVIDED = Object.new
7
+
8
+ ##
9
+ # Add values to metadata
10
+ #
11
+ # @overload add_metadata(metadata, section, data)
12
+ # Merges data into the given section of metadata
13
+ # @param metadata [Hash] The metadata hash to operate on
14
+ # @param section [String, Symbol]
15
+ # @param data [Hash]
16
+ #
17
+ # @overload add_metadata(metadata, section, key, value)
18
+ # Sets key to value in the given section of metadata. If the value is nil
19
+ # the key will be deleted
20
+ # @param metadata [Hash] The metadata hash to operate on
21
+ # @param section [String, Symbol]
22
+ # @param key [String, Symbol]
23
+ # @param value
24
+ #
25
+ # @return [void]
26
+ def add_metadata(metadata, section, key_or_data, value = NOT_PROVIDED)
27
+ case value
28
+ when NOT_PROVIDED
29
+ merge_metadata(metadata, section, key_or_data)
30
+ when nil
31
+ clear_metadata(metadata, section, key_or_data)
32
+ else
33
+ overwrite_metadata(metadata, section, key_or_data, value)
34
+ end
35
+ end
36
+
37
+ ##
38
+ # Clear values from metadata
39
+ #
40
+ # @overload clear_metadata(metadata, section)
41
+ # Clears the given section of metadata
42
+ # @param metadata [Hash] The metadata hash to operate on
43
+ # @param section [String, Symbol]
44
+ #
45
+ # @overload clear_metadata(metadata, section, key)
46
+ # Clears the key in the given section of metadata
47
+ # @param metadata [Hash] The metadata hash to operate on
48
+ # @param section [String, Symbol]
49
+ # @param key [String, Symbol]
50
+ #
51
+ # @return [void]
52
+ def clear_metadata(metadata, section, key = nil)
53
+ if key.nil?
54
+ metadata.delete(section)
55
+ elsif metadata[section]
56
+ metadata[section].delete(key)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ ##
63
+ # Merge new metadata into the existing metadata
64
+ #
65
+ # Any keys with a 'nil' value in the new metadata will be deleted from the
66
+ # existing metadata
67
+ #
68
+ # @param existing_metadata [Hash]
69
+ # @param section [String, Symbol]
70
+ # @param new_metadata [Hash]
71
+ # @return [void]
72
+ def merge_metadata(existing_metadata, section, new_metadata)
73
+ return unless new_metadata.is_a?(Hash)
74
+
75
+ existing_metadata[section] ||= {}
76
+ data = existing_metadata[section]
77
+
78
+ new_metadata.each do |key, value|
79
+ if value.nil?
80
+ data.delete(key)
81
+ else
82
+ data[key] = value
83
+ end
84
+ end
85
+ end
86
+
87
+ ##
88
+ # Overwrite the value in metadata's section & key
89
+ #
90
+ # @param metadata [Hash]
91
+ # @param section [String, Symbol]
92
+ # @param key [String, Symbol]
93
+ # @param value
94
+ # @return [void]
95
+ def overwrite_metadata(metadata, section, key, value)
96
+ return unless key.is_a?(String) || key.is_a?(Symbol)
97
+
98
+ metadata[section] ||= {}
99
+ metadata[section][key] = value
100
+ end
101
+ end
102
+ end