Commit 4cf6b44fd88178c7c46650547b951daccf25c773

Authored by Stephen Crosby
2 parents 24db1d16 ddb32e10
Exists in master and in 1 other branch production

Merge pull request #946 from stevecrozz/configurable_backtraces

configurable notice fingerprinting
.gitignore
... ... @@ -20,3 +20,4 @@ bundle
20 20 coverage
21 21 *#
22 22 .ruby-version
  23 +NOTES
... ...
README.md
... ... @@ -88,6 +88,19 @@ Deployment
88 88 ----------
89 89 See [notes on deployment](docs/deployment.md)
90 90  
  91 +Notice Grouping
  92 +---------------
  93 +The way Errbit arranges notices into error groups is configurable. By default,
  94 +Errbit uses the notice's error class, error message, complete backtrace,
  95 +component (or controller), action and environment name to generate a unique
  96 +fingerprint for every notice. Notices with identical fingerprints appear in the
  97 +UI as different occurences of the same error and notices with differing
  98 +fingerprints are displayed as separate errors.
  99 +
  100 +Changing the fingerprinter (under the 'config' menu) applies to all apps and
  101 +the change affects only notices that arrive after the change. If you want to
  102 +refingerprint old notices, you can run `rake errbit:notice_refingerprint`.
  103 +
91 104 Authentication
92 105 --------------
93 106 ### Configuring GitHub authentication:
... ... @@ -230,21 +243,6 @@ Airbrake.setProject("ERRBIT API KEY", "ERRBIT API KEY");
230 243 Airbrake.setHost("http://errbit.yourdomain.com");
231 244 ```
232 245  
233   -Using custom fingerprinting methods
234   ------------------------------------
235   -
236   -Errbit collates errors into groups using a fingerprinting strategy. If you find
237   -your errors are not getting grouped the way you would expect, you may need to
238   -implement your own strategy. A fingerprinting strategy is just a class that
239   -implements a ::generate class method. See the classes in
240   -`app/models/fingerprint/` if you need some inspiration. You can install it with
241   -an initializer like:
242   -
243   -```ruby
244   -# config/initializers/fingerprint.rb
245   -ErrorReport.fingerprint_strategy = MyStrategy
246   -```
247   -
248 246 Plugins and Integrations
249 247 ------------------------
250 248 You can extend Errbit by adding Ruby gems and plugins which are typically gems.
... ...
app/controllers/site_config_controller.rb 0 → 100644
... ... @@ -0,0 +1,27 @@
  1 +class SiteConfigController < ApplicationController
  2 + before_action :require_admin!
  3 +
  4 + def index
  5 + @config = SiteConfig.document
  6 + end
  7 +
  8 + def update
  9 + SiteConfig.document.update_attributes(
  10 + notice_fingerprinter: filtered_update_params)
  11 + flash[:success] = 'Updated site config'
  12 + redirect_to action: :index
  13 + end
  14 +
  15 + private def filtered_update_params
  16 + params
  17 + .require(:site_config)
  18 + .require(:notice_fingerprinter_attributes)
  19 + .permit(
  20 + :error_class,
  21 + :message,
  22 + :backtrace_lines,
  23 + :component,
  24 + :action,
  25 + :environment_name)
  26 + end
  27 +end
... ...
app/interactors/notice_refingerprinter.rb 0 → 100644
... ... @@ -0,0 +1,38 @@
  1 +class NoticeRefingerprinter
  2 + LOG_EVERY = 100
  3 + LOG_ITR = '%.1f%% complete, %i notice(s) remaining'
  4 + LOG_START = 'Regenerating notice fingerprints for %i notices'
  5 +
  6 + def self.run
  7 + count = Notice.count
  8 + puts format(LOG_START, count)
  9 +
  10 + Notice.no_timeout.each_with_index do |notice, index|
  11 + refingerprint(notice)
  12 +
  13 + next unless (index + 1) % LOG_EVERY == 0
  14 + puts format(LOG_ITR, (index * 100 / count), count - index)
  15 + end
  16 +
  17 + puts 'Finished generating notice fingerprints'
  18 + puts 'Destroying orphaned err records'
  19 +
  20 + Err.each { |e| e.destroy if e.notices.size == 0 }
  21 +
  22 + puts 'Finished destroying orphaned err records'
  23 + end
  24 +
  25 + def self.refingerprint(notice)
  26 + app = notice.app
  27 + notice.err = app.find_or_create_err!(
  28 + error_class: notice.error_class,
  29 + environment: notice.environment_name,
  30 + fingerprint: app.notice_fingerprinter.generate(app.api_key, notice, notice.backtrace)
  31 + )
  32 + notice.save!
  33 + end
  34 +
  35 + def self.puts(*args)
  36 + Rails.logger.info(*args)
  37 + end
  38 +end
... ...
app/interactors/problem_merge.rb
... ... @@ -16,13 +16,7 @@ class ProblemMerge
16 16 problem.reload # deference all associate objet to avoid delete him after
17 17 ProblemDestroy.execute(problem)
18 18 end
19   - reset_cached_attributes
  19 + merged_problem.recache
20 20 merged_problem
21 21 end
22   -
23   - private
24   -
25   - def reset_cached_attributes
26   - ProblemUpdaterCache.new(merged_problem).update
27   - end
28 22 end
... ...
app/interactors/problem_recacher.rb 0 → 100644
... ... @@ -0,0 +1,24 @@
  1 +class ProblemRecacher
  2 + LOG_EVERY = 100
  3 + LOG_ITR = '%.1f%% complete, %i problem(s) remaining'
  4 + LOG_START = 'Re-caching problem attributes for %i problems'
  5 +
  6 + def self.run
  7 + count = Problem.count
  8 + puts format(LOG_START, count)
  9 +
  10 + Problem.no_timeout.each_with_index do |problem, index|
  11 + problem.recache
  12 + problem.destroy if problem.notices_count == 0
  13 +
  14 + next unless (index + 1) % LOG_EVERY == 0
  15 + puts format(LOG_ITR, (index * 100 / count), count - index)
  16 + end
  17 +
  18 + puts "Finished re-caching problem attributes"
  19 + end
  20 +
  21 + def self.puts(*args)
  22 + Rails.logger.info(*args)
  23 + end
  24 +end
... ...
app/interactors/problem_updater_cache.rb
... ... @@ -1,88 +0,0 @@
1   -class ProblemUpdaterCache
2   - def initialize(problem, notice=nil)
3   - @problem = problem
4   - @notice = notice
5   - end
6   - attr_reader :problem
7   -
8   - ##
9   - # Update cache information about child associate to this problem
10   - #
11   - # update the notices count, and some notice informations
12   - #
13   - # @return [ Problem ] the problem with this update
14   - #
15   - def update
16   - update_notices_count
17   - update_notices_cache
18   - problem
19   - end
20   -
21   - private
22   -
23   - def update_notices_count
24   - if @notice
25   - problem.inc(notices_count: 1)
26   - else
27   - problem.update_attribute(
28   - :notices_count, problem.notices.count
29   - )
30   - end
31   - end
32   -
33   - ##
34   - # Update problem statistique from some notice information
35   - #
36   - def update_notices_cache
37   - first_notice = notices.first
38   - last_notice = notices.last
39   - notice ||= @notice || first_notice
40   -
41   - attrs = {}
42   - attrs[:first_notice_at] = first_notice.created_at if first_notice
43   - attrs[:last_notice_at] = last_notice.created_at if last_notice
44   - attrs.merge!(
45   - :message => notice.message,
46   - :where => notice.where,
47   - :messages => attribute_count(:message, messages),
48   - :hosts => attribute_count(:host, hosts),
49   - :user_agents => attribute_count(:user_agent_string, user_agents)
50   - ) if notice
51   - problem.update_attributes!(attrs)
52   - end
53   -
54   - def notices
55   - @notices ||= @notice ? [@notice].sort(&:created_at) : problem.notices.order_by([:created_at, :asc])
56   - end
57   -
58   - def messages
59   - @notice ? problem.messages : {}
60   - end
61   -
62   - def hosts
63   - @notice ? problem.hosts : {}
64   - end
65   -
66   - def user_agents
67   - @notice ? problem.user_agents : {}
68   - end
69   -
70   - private
71   -
72   - def attribute_count(value, init)
73   - init.tap do |counts|
74   - notices.each do |notice|
75   - counts[attribute_index(notice.send(value))] ||= {
76   - 'value' => notice.send(value),
77   - 'count' => 0
78   - }
79   - counts[attribute_index(notice.send(value))]['count'] += 1
80   - end
81   - end
82   - end
83   -
84   - def attribute_index(value)
85   - @attributes_index ||= {}
86   - @attributes_index[value.to_s] ||= Digest::MD5.hexdigest(value.to_s)
87   - end
88   -end
app/models/app.rb
... ... @@ -27,6 +27,7 @@ class App
27 27 embeds_many :deploys
28 28 embeds_one :issue_tracker, :class_name => 'IssueTracker'
29 29 embeds_one :notification_service
  30 + embeds_one :notice_fingerprinter, autobuild: true
30 31  
31 32 has_many :problems, :inverse_of => :app, :dependent => :destroy
32 33  
... ... @@ -38,6 +39,7 @@ class App
38 39 validates_uniqueness_of :name, :allow_blank => true
39 40 validates_uniqueness_of :api_key, :allow_blank => true
40 41 validates_associated :watchers
  42 + validates_associated :notice_fingerprinter
41 43 validate :check_issue_tracker
42 44  
43 45 accepts_nested_attributes_for :watchers, :allow_destroy => true,
... ... @@ -46,6 +48,7 @@ class App
46 48 :reject_if => proc { |attrs| !ErrbitPlugin::Registry.issue_trackers.keys.map(&:to_s).include?(attrs[:type_tracker].to_s) }
47 49 accepts_nested_attributes_for :notification_service, :allow_destroy => true,
48 50 :reject_if => proc { |attrs| !NotificationService.subclasses.map(&:to_s).include?(attrs[:type].to_s) }
  51 + accepts_nested_attributes_for :notice_fingerprinter
49 52  
50 53 scope :watched_by, ->(user) do
51 54 where watchers: { "$elemMatch" => { "user_id" => user.id } }
... ... @@ -62,16 +65,15 @@ class App
62 65 # * <tt>:fingerprint</tt> - a unique value identifying the notice
63 66 #
64 67 def find_or_create_err!(attrs)
65   - Err.where(
66   - :fingerprint => attrs[:fingerprint]
67   - ).first || (
68   - problem = problems.create!(
69   - error_class: attrs[:error_class],
70   - environment: attrs[:environment],
71   - app_name: name
72   - )
73   - problem.errs.create!(attrs.slice(:fingerprint, :problem_id))
  68 + err = Err.where(fingerprint: attrs[:fingerprint]).first
  69 + return err if err
  70 +
  71 + problem = problems.create!(
  72 + error_class: attrs[:error_class],
  73 + environment: attrs[:environment],
  74 + app_name: name
74 75 )
  76 + problem.errs.create!(attrs.slice(:fingerprint, :problem_id))
75 77 end
76 78  
77 79 # Mongoid Bug: find(id) on association proxies returns an Enumerator
... ...
app/models/error_report.rb
... ... @@ -26,10 +26,6 @@ class ErrorReport
26 26 attr_reader :server_environment
27 27 attr_reader :user_attributes
28 28  
29   - cattr_accessor :fingerprint_strategy do
30   - Fingerprint::Sha1
31   - end
32   -
33 29 def initialize(xml_or_attributes)
34 30 @attributes = xml_or_attributes
35 31 @attributes = Hoptoad.parse_xml!(@attributes) if @attributes.is_a? String
... ... @@ -130,9 +126,7 @@ class ErrorReport
130 126 Gem::Version.new(app_version) >= Gem::Version.new(current_version)
131 127 end
132 128  
133   - private
134   -
135 129 def fingerprint
136   - @fingerprint ||= fingerprint_strategy.generate(notice, api_key)
  130 + app.notice_fingerprinter.generate(api_key, notice, backtrace)
137 131 end
138 132 end
... ...
app/models/fingerprint/md5.rb
... ... @@ -1,22 +0,0 @@
1   -require 'digest/md5'
2   -
3   -module Fingerprint
4   - class MD5 < Sha1
5   - def to_s
6   - Digest::MD5.hexdigest(fingerprint_source)
7   - end
8   -
9   - def fingerprint_source
10   - location['method'] &&= sanitized_method_signature
11   - end
12   -
13   - private
14   - def sanitized_method_signature
15   - location['method'].gsub(/[0-9]+|FRAGMENT/, '#').gsub(/_+#/, '_#')
16   - end
17   -
18   - def location
19   - notice.backtrace.lines.first
20   - end
21   - end
22   -end
app/models/fingerprint/sha1.rb
... ... @@ -1,43 +0,0 @@
1   -require 'digest/sha1'
2   -
3   -module Fingerprint
4   - class Sha1
5   -
6   - attr_reader :notice, :api_key
7   -
8   - def self.generate(notice, api_key)
9   - self.new(notice, api_key).to_s
10   - end
11   -
12   - def initialize(notice, api_key)
13   - @notice = notice
14   - @api_key = api_key
15   - end
16   -
17   - def to_s
18   - Digest::SHA1.hexdigest(fingerprint_source.to_s)
19   - end
20   -
21   - def fingerprint_source
22   - {
23   - :file_or_message => file_or_message,
24   - :error_class => notice.error_class,
25   - :component => notice.component || 'unknown',
26   - :action => notice.action,
27   - :environment => notice.environment_name || 'development',
28   - :api_key => api_key
29   - }
30   - end
31   -
32   - def file_or_message
33   - @file_or_message ||= unified_message + notice.backtrace.fingerprint
34   - end
35   -
36   - # filter memory addresses out of object strings
37   - # example: "#<Object:0x007fa2b33d9458>" becomes "#<Object>"
38   - def unified_message
39   - notice.message.gsub(/(#<.+?):[0-9a-f]x[0-9a-f]+(>)/, '\1\2')
40   - end
41   -
42   - end
43   -end
app/models/notice.rb
... ... @@ -29,9 +29,7 @@ class Notice
29 29 scope :ordered, ->{ order_by(:created_at.asc) }
30 30 scope :reverse_ordered, ->{ order_by(:created_at.desc) }
31 31 scope :for_errs, Proc.new { |errs|
32   - if (ids = errs.all.map(&:id)) && ids.present?
33   - where(:err_id.in => ids)
34   - end
  32 + where(:err_id.in => errs.all.map(&:id))
35 33 }
36 34  
37 35 def user_agent
... ... @@ -48,7 +46,8 @@ class Notice
48 46 end
49 47  
50 48 def environment_name
51   - server_environment['server-environment'] || server_environment['environment-name']
  49 + n = server_environment['server-environment'] || server_environment['environment-name']
  50 + n.blank? ? 'development' : n
52 51 end
53 52  
54 53 def component
... ... @@ -119,6 +118,12 @@ class Notice
119 118 end
120 119 end
121 120  
  121 + # filter memory addresses out of object strings
  122 + # example: "#<Object:0x007fa2b33d9458>" becomes "#<Object>"
  123 + def filtered_message
  124 + message.gsub(/(#<.+?):[0-9a-f]x[0-9a-f]+(>)/, '\1\2')
  125 + end
  126 +
122 127 protected
123 128  
124 129 def problem_recache
... ...
app/models/notice_fingerprinter.rb 0 → 100644
... ... @@ -0,0 +1,32 @@
  1 +class NoticeFingerprinter
  2 + include Mongoid::Document
  3 + include Mongoid::Timestamps
  4 +
  5 + field :error_class, default: true, type: Boolean
  6 + field :message, default: true, type: Boolean
  7 + field :backtrace_lines, default: -1, type: Integer
  8 + field :component, default: true, type: Boolean
  9 + field :action, default: true, type: Boolean
  10 + field :environment_name, default: true, type: Boolean
  11 + field :source, type: String
  12 +
  13 + embedded_in :app
  14 + embedded_in :site_config
  15 +
  16 + def generate(api_key, notice, backtrace)
  17 + material = [ api_key ]
  18 + material << notice.error_class if error_class
  19 + material << notice.filtered_message if message
  20 + material << notice.component if component
  21 + material << notice.action if action
  22 + material << notice.environment_name if environment_name
  23 +
  24 + if backtrace_lines < 0
  25 + material << backtrace.lines
  26 + else
  27 + material << backtrace.lines.slice(0, backtrace_lines)
  28 + end
  29 +
  30 + Digest::MD5.hexdigest(material.reduce('') { |c, m| c << m.to_s; c })
  31 + end
  32 +end
... ...
app/models/problem.rb
... ... @@ -142,16 +142,22 @@ class Problem
142 142 { "$match" => { err_id: { "$in" => err_ids } } },
143 143 { "$group" => { _id: "$#{v}", count: {"$sum" => 1} } }
144 144 ]).each do |agg|
145   - next if agg[:_id] == nil
146   -
147   - send(k)[Digest::MD5.hexdigest(agg[:_id])] = {
148   - value: agg[:_id],
149   - count: agg[:count]
  145 + send(k)[Digest::MD5.hexdigest(agg[:_id] || 'N/A')] = {
  146 + 'value' => agg[:_id] || 'N/A',
  147 + 'count' => agg[:count]
150 148 }
151 149 end
152 150 end
153 151  
154   - self.notices_count = Notice.where({ err_id: { "$in" => err_ids }}).count
  152 + first_notice = notices.order_by([:created_at, :asc]).first
  153 + last_notice = notices.order_by([:created_at, :desc]).first
  154 +
  155 + self.notices_count = notices.count
  156 + self.first_notice_at = first_notice.created_at if first_notice
  157 + self.message = first_notice.message if first_notice
  158 + self.where = first_notice.where if first_notice
  159 + self.last_notice_at = last_notice.created_at if last_notice
  160 +
155 161 save
156 162 end
157 163  
... ...
app/models/site_config.rb 0 → 100644
... ... @@ -0,0 +1,36 @@
  1 +class SiteConfig
  2 + CONFIG_SOURCE_SITE = 'site'.freeze
  3 + CONFIG_SOURCE_APP = 'app'.freeze
  4 +
  5 + include Mongoid::Document
  6 + include Mongoid::Timestamps
  7 +
  8 + before_save :denormalize
  9 +
  10 + embeds_one :notice_fingerprinter, autobuild: true
  11 + validates_associated :notice_fingerprinter
  12 + accepts_nested_attributes_for :notice_fingerprinter
  13 +
  14 + # Get the one and only SiteConfig document
  15 + def self.document
  16 + first || create
  17 + end
  18 +
  19 + # Denormalize SiteConfig onto individual apps so that this record doesn't
  20 + # need to be accessed when inserting new error notices
  21 + def denormalize
  22 + notice_fingerprinter_attributes = notice_fingerprinter.attributes.tap do |attrs|
  23 + attrs.delete('_id')
  24 + attrs[:source] = :site
  25 + end
  26 +
  27 + App.each do |app|
  28 + f = app.notice_fingerprinter
  29 +
  30 + if !f || f.source == CONFIG_SOURCE_SITE
  31 + app.update_attributes(
  32 + notice_fingerprinter: notice_fingerprinter_attributes)
  33 + end
  34 + end
  35 + end
  36 +end
... ...
app/views/shared/_navigation.html.haml
... ... @@ -13,4 +13,8 @@
13 13 = link_to users_path do
14 14 %i.fa.fa-users
15 15 = t('.users')
  16 + %li{:class => active_if_here(:site_config)}
  17 + = link_to site_config_index_path do
  18 + %i.fa.fa-wrench
  19 + = t('.config')
16 20 %div.clear
... ...
app/views/site_config/index.html.haml 0 → 100644
... ... @@ -0,0 +1,39 @@
  1 +- content_for :title, 'Config'
  2 +
  3 +- content_for :action_bar, link_to('cancel', users_path, :class => 'button')
  4 +
  5 += form_for @config, url: site_config_index_path, method: 'put' do |f|
  6 + = errors_for @config
  7 +
  8 + %fieldset
  9 + %legend Notice Fingerprinter
  10 + %p
  11 + The notice fingerprinter governs how error notifications are grouped.
  12 + Each item counts toward an error's uniqueness if enabled.
  13 +
  14 + = f.fields_for :notice_fingerprinter do |g|
  15 + .checkbox
  16 + = g.check_box :error_class
  17 + = g.label :error_class, 'Error class'
  18 +
  19 + .checkbox
  20 + = g.check_box :message
  21 + = g.label :message, 'Error message'
  22 +
  23 + %div
  24 + = g.label :backtrace_lines, 'Number of backtrace lines (-1 for unlimited)'
  25 + = g.text_field :backtrace_lines
  26 +
  27 + .checkbox
  28 + = g.check_box :component
  29 + = g.label :component, 'Component (or controller)'
  30 +
  31 + .checkbox
  32 + = g.check_box :action
  33 + = g.label :action, 'Action'
  34 +
  35 + .checkbox
  36 + = g.check_box :environment_name
  37 + = g.label :environment_name, 'Environment name'
  38 +
  39 + %div.buttons= f.submit 'Update Config'
... ...
config/routes.rb
... ... @@ -14,7 +14,14 @@ Rails.application.routes.draw do
14 14 delete :unlink_github
15 15 end
16 16 end
17   - resources :problems, :only => [:index] do
  17 +
  18 + resources :site_config, :only => [:index] do
  19 + collection do
  20 + put :update
  21 + end
  22 + end
  23 +
  24 + resources :problems, :only => [:index] do
18 25 collection do
19 26 post :destroy_several
20 27 post :resolve_several
... ...
lib/tasks/errbit/database.rake
1   -require 'digest/sha1'
2   -
3 1 namespace :errbit do
4   - namespace :db do
5   -
6   - desc "Updates cached attributes on Problem"
7   - task :update_problem_attrs => :environment do
8   - puts "Updating problems"
9   - Problem.no_timeout.all.each do |problem|
10   - ProblemUpdaterCache.new(problem).update
11   - end
12   - end
13   -
14   - desc "Updates Problem#notices_count"
15   - task :update_notices_count => :environment do
16   - puts "Updating problem.notices_count"
17   - Problem.no_timeout.all.each do |pr|
18   - pr.update_attributes(:notices_count => pr.notices.count)
19   - end
20   - end
21   -
22   - desc "Delete resolved errors from the database. (Useful for limited heroku databases)"
23   - task :clear_resolved => :environment do
24   - require 'resolved_problem_clearer'
25   - puts "=== Cleared #{ResolvedProblemClearer.new.execute} resolved errors from the database."
26   - end
27   -
28   - desc "Regenerate fingerprints"
29   - task :regenerate_fingerprints => :environment do
30   -
31   - def normalize_backtrace(backtrace)
32   - backtrace[0...3].map do |trace|
33   - trace.merge 'method' => trace['method'].to_s.gsub(/[0-9_]{10,}+/, "__FRAGMENT__")
34   - end
35   - end
  2 + desc "Updates cached attributes on Problem"
  3 + task :problem_recache => :environment do
  4 + ProblemRecacher.run
  5 + end
36 6  
37   - def fingerprint(source)
38   - Digest::SHA1.hexdigest(source.to_s)
39   - end
  7 + desc "Delete resolved errors from the database. (Useful for limited heroku databases)"
  8 + task :clear_resolved => :environment do
  9 + require 'resolved_problem_clearer'
  10 + puts "=== Cleared #{ResolvedProblemClearer.new.execute} resolved errors from the database."
  11 + end
40 12  
41   - puts "Regenerating Err fingerprints"
42   - Err.create_indexes
43   - Err.all.each do |err|
44   - next if err.notices.count == 0
45   - source = {
46   - :backtrace => normalize_backtrace(err.notices.first.backtrace).to_s,
47   - :error_class => err.error_class,
48   - :component => err.component,
49   - :action => err.action,
50   - :environment => err.environment,
51   - :api_key => err.app.api_key
52   - }
53   - err.update_attributes(:fingerprint => fingerprint(source))
54   - end
55   - end
  13 + desc "Regenerate fingerprints"
  14 + task :notice_refingerprint => :environment do
  15 + NoticeRefingerprinter.run
  16 + ProblemRecacher.run
  17 + end
56 18  
57   - desc "Remove notices in batch"
58   - task :notices_delete, [ :problem_id ] => [ :environment ] do
59   - BATCH_SIZE = 1000
60   - if args[:problem_id]
61   - item_count = Problem.find(args[:problem_id]).notices.count
62   - removed_count = 0
63   - puts "Notices to remove: #{item_count}"
64   - while item_count > 0
65   - Problem.find(args[:problem_id]).notices.limit(BATCH_SIZE).each do |notice|
66   - notice.remove
67   - removed_count += 1
68   - end
69   - item_count -= BATCH_SIZE
70   - puts "Removed #{removed_count} notices"
  19 + desc "Remove notices in batch"
  20 + task :notices_delete, [ :problem_id ] => [ :environment ] do
  21 + BATCH_SIZE = 1000
  22 + if args[:problem_id]
  23 + item_count = Problem.find(args[:problem_id]).notices.count
  24 + removed_count = 0
  25 + puts "Notices to remove: #{item_count}"
  26 + while item_count > 0
  27 + Problem.find(args[:problem_id]).notices.limit(BATCH_SIZE).each do |notice|
  28 + notice.remove
  29 + removed_count += 1
71 30 end
  31 + item_count -= BATCH_SIZE
  32 + puts "Removed #{removed_count} notices"
72 33 end
73 34 end
74 35 end
... ...
spec/controllers/site_config_controller_spec.rb 0 → 100644
... ... @@ -0,0 +1,48 @@
  1 +describe SiteConfigController, type: 'controller' do
  2 + it_requires_admin_privileges for: {
  3 + index: :get,
  4 + update: :put
  5 + }
  6 +
  7 + let(:admin) { Fabricate(:admin) }
  8 +
  9 + before { sign_in admin }
  10 +
  11 + describe '#index' do
  12 + it 'has an index action' do
  13 + get :index
  14 + end
  15 + end
  16 +
  17 + describe '#update' do
  18 + it 'updates' do
  19 + put :update, site_config: {
  20 + notice_fingerprinter_attributes: {
  21 + backtrace_lines: 3,
  22 + environment_name: false
  23 + }
  24 + }
  25 +
  26 + fingerprinter = SiteConfig.document.notice_fingerprinter
  27 +
  28 + expect(fingerprinter.environment_name).to be false
  29 + expect(fingerprinter.backtrace_lines).to be 3
  30 + end
  31 +
  32 + it 'redirects to the index' do
  33 + put :update, site_config: {
  34 + notice_fingerprinter_attributes: { error_class: true }
  35 + }
  36 +
  37 + expect(response).to redirect_to(site_config_index_path)
  38 + end
  39 +
  40 + it 'flashes a confirmation' do
  41 + put :update, site_config: {
  42 + notice_fingerprinter_attributes: { error_class: true }
  43 + }
  44 +
  45 + expect(request.flash[:success]).to eq 'Updated site config'
  46 + end
  47 + end
  48 +end
... ...
spec/fabricators/backtrace_fabricator.rb 0 → 100644
... ... @@ -0,0 +1,9 @@
  1 +Fabricator :backtrace do
  2 + lines(:count => 99) do
  3 + {
  4 + number: rand(999),
  5 + file: "/path/to/file/#{SecureRandom.hex(4)}.rb",
  6 + method: ActiveSupport.methods.shuffle.first
  7 + }
  8 + end
  9 +end
... ...
spec/fabricators/err_fabricator.rb
... ... @@ -2,28 +2,3 @@ Fabricator :err do
2 2 problem
3 3 fingerprint 'some-finger-print'
4 4 end
5   -
6   -Fabricator :notice do
7   - app
8   - err
9   - message 'FooError: Too Much Bar'
10   - backtrace
11   - server_environment { {'environment-name' => 'production'} }
12   - request {{ 'component' => 'foo', 'action' => 'bar' }}
13   - notifier {{ 'name' => 'Notifier', 'version' => '1', 'url' => 'http://toad.com' }}
14   -
15   - after_create do
16   - Problem.cache_notice(err.problem_id, self)
17   - problem.reload
18   - end
19   -end
20   -
21   -Fabricator :backtrace do
22   - lines(:count => 99) do
23   - {
24   - number: rand(999),
25   - file: "/path/to/file/#{SecureRandom.hex(4)}.rb",
26   - method: ActiveSupport.methods.shuffle.first
27   - }
28   - end
29   -end
... ...
spec/fabricators/notice_fabricator.rb 0 → 100644
... ... @@ -0,0 +1,15 @@
  1 +Fabricator :notice do
  2 + app
  3 + err
  4 + error_class 'FooError'
  5 + message 'Too Much Bar'
  6 + backtrace
  7 + server_environment {{ 'environment-name' => 'production' }}
  8 + request {{ 'component' => 'foo', 'action' => 'bar' }}
  9 + notifier {{ 'name' => 'Notifier', 'version' => '1', 'url' => 'http://toad.com' }}
  10 +
  11 + after_create do
  12 + Problem.cache_notice(err.problem_id, self)
  13 + problem.reload
  14 + end
  15 +end
... ...
spec/interactors/notice_refingerprinter_spec.rb 0 → 100644
... ... @@ -0,0 +1,52 @@
  1 +describe NoticeRefingerprinter do
  2 + let(:app) { Fabricate(:app) }
  3 + let(:backtrace) do
  4 + Fabricate(:backtrace)
  5 + end
  6 +
  7 + before do
  8 + notices
  9 + end
  10 +
  11 + context 'identical backtraces' do
  12 + let(:notices) do
  13 + 5.times.map do
  14 + notice = Fabricate(:notice, backtrace: backtrace, app: app)
  15 + notice.save!
  16 + notice
  17 + end
  18 + end
  19 +
  20 + it 'has only one err' do
  21 + described_class.run
  22 + expect(Err.count).to eq(1)
  23 + end
  24 + end
  25 +
  26 + context 'minor backtrace differences' do
  27 + let(:notices) do
  28 + line_numbers = [1, 1, 2, 2, 3]
  29 + 5.times.map do
  30 + b = backtrace.clone
  31 + b.lines[5][:number] = line_numbers.shift
  32 + b.save!
  33 + notice = Fabricate(:notice, backtrace: b, app: app)
  34 + notice.save!
  35 + end
  36 + end
  37 +
  38 + it 'has three errs with default fingerprinter' do
  39 + described_class.run
  40 + expect(Err.count).to eq(3)
  41 + end
  42 +
  43 + it 'has one err when limiting backtrace line count' do
  44 + fingerprinter = app.notice_fingerprinter
  45 + fingerprinter.backtrace_lines = 4
  46 + fingerprinter.save!
  47 +
  48 + described_class.run
  49 + expect(Err.count).to eq(1)
  50 + end
  51 + end
  52 +end
... ...
spec/interactors/problem_merge_spec.rb
... ... @@ -47,7 +47,7 @@ describe ProblemMerge do
47 47 end
48 48  
49 49 it 'update problem cache' do
50   - expect(ProblemUpdaterCache).to receive(:new).with(problem).and_return(double(:update => true))
  50 + expect(problem).to receive(:recache)
51 51 problem_merge.merge
52 52 end
53 53  
... ...
spec/interactors/problem_recacher_spec.rb 0 → 100644
... ... @@ -0,0 +1,38 @@
  1 +describe ProblemRecacher do
  2 + let(:app) { Fabricate(:app) }
  3 + let(:backtrace) do
  4 + Fabricate(:backtrace)
  5 + end
  6 +
  7 + before do
  8 + notices
  9 +
  10 + NoticeRefingerprinter.run
  11 + described_class.run
  12 + end
  13 +
  14 + context 'minor backtrace differences' do
  15 + let(:notices) do
  16 + line_numbers = [1, 1, 2, 2, 3]
  17 + 5.times.map do
  18 + b = backtrace.clone
  19 + b.lines[5][:number] = line_numbers.shift
  20 + b.save!
  21 + notice = Fabricate(:notice, backtrace: b, app: app)
  22 + notice.save!
  23 + notice
  24 + end
  25 + end
  26 +
  27 + it 'has three problems for the five notices' do
  28 + expect(Notice.count).to eq(5)
  29 + expect(Problem.count).to eq(3)
  30 + end
  31 +
  32 + it 'the problems have the right cached attributes' do
  33 + problem = notices.first.reload.problem
  34 +
  35 + expect(problem.notices_count).to eq(2)
  36 + end
  37 + end
  38 +end
... ...
spec/interactors/problem_updater_cache_spec.rb
... ... @@ -1,145 +0,0 @@
1   -describe ProblemUpdaterCache do
2   - let(:problem) { Fabricate(:problem_with_errs) }
3   - let(:first_errs) { problem.errs }
4   - let!(:notice) { Fabricate(:notice, :err => first_errs.first) }
5   -
6   - describe "#update" do
7   - context "without notice pass args" do
8   - before do
9   - problem.update_attribute(:notices_count, 0)
10   - end
11   -
12   - it 'update the notice_count' do
13   - expect {
14   - ProblemUpdaterCache.new(problem).update
15   - }.to change{
16   - problem.notices_count
17   - }.from(0).to(1)
18   - end
19   -
20   - context "with only one notice" do
21   - before do
22   - problem.update_attributes!(:messages => {})
23   - ProblemUpdaterCache.new(problem).update
24   - end
25   -
26   - it 'update information about this notice' do
27   - expect(problem.message).to eq notice.message
28   - expect(problem.where).to eq notice.where
29   - end
30   -
31   - it 'update first_notice_at' do
32   - expect(problem.first_notice_at).to eq notice.reload.created_at
33   - end
34   -
35   - it 'update last_notice_at' do
36   - expect(problem.last_notice_at).to eq notice.reload.created_at
37   - end
38   -
39   - it 'update stats messages' do
40   - expect(problem.messages).to eq({
41   - Digest::MD5.hexdigest(notice.message) => {'value' => notice.message, 'count' => 1}
42   - })
43   - end
44   -
45   - it 'update stats hosts' do
46   - expect(problem.hosts).to eq({
47   - Digest::MD5.hexdigest(notice.host) => {'value' => notice.host, 'count' => 1}
48   - })
49   - end
50   -
51   - it 'update stats user_agents' do
52   - expect(problem.user_agents).to eq({
53   - Digest::MD5.hexdigest(notice.user_agent_string) => {'value' => notice.user_agent_string, 'count' => 1}
54   - })
55   - end
56   - end
57   -
58   - context "with several notices" do
59   - let!(:notice_2) { Fabricate(:notice, :err => first_errs.first) }
60   - let!(:notice_3) { Fabricate(:notice, :err => first_errs.first) }
61   - before do
62   - problem.update_attributes!(:messages => {})
63   - ProblemUpdaterCache.new(problem).update
64   - end
65   - it 'update information about this notice' do
66   - expect(problem.message).to eq notice.message
67   - expect(problem.where).to eq notice.where
68   - end
69   -
70   - it 'update first_notice_at' do
71   - expect(problem.first_notice_at.to_i).to be_within(2).of(notice.created_at.to_i)
72   - end
73   -
74   - it 'update last_notice_at' do
75   - expect(problem.last_notice_at.to_i).to be_within(2).of(notice.created_at.to_i)
76   - end
77   -
78   - it 'update stats messages' do
79   - expect(problem.messages).to eq({
80   - Digest::MD5.hexdigest(notice.message) => {'value' => notice.message, 'count' => 3}
81   - })
82   - end
83   -
84   - it 'update stats hosts' do
85   - expect(problem.hosts).to eq({
86   - Digest::MD5.hexdigest(notice.host) => {'value' => notice.host, 'count' => 3}
87   - })
88   - end
89   -
90   - it 'update stats user_agents' do
91   - expect(problem.user_agents).to eq({
92   - Digest::MD5.hexdigest(notice.user_agent_string) => {'value' => notice.user_agent_string, 'count' => 3}
93   - })
94   - end
95   -
96   - end
97   - end
98   -
99   - context "with notice pass in args" do
100   -
101   - before do
102   - ProblemUpdaterCache.new(problem, notice).update
103   - end
104   -
105   - it 'increase notices_count by 1' do
106   - expect {
107   - ProblemUpdaterCache.new(problem, notice).update
108   - }.to change{
109   - problem.notices_count
110   - }.by(1)
111   - end
112   -
113   - it 'update information about this notice' do
114   - expect(problem.message).to eq notice.message
115   - expect(problem.where).to eq notice.where
116   - end
117   -
118   - it 'update first_notice_at' do
119   - expect(problem.first_notice_at).to eq notice.created_at
120   - end
121   -
122   - it 'update last_notice_at' do
123   - expect(problem.last_notice_at).to eq notice.created_at
124   - end
125   -
126   - it 'inc stats messages' do
127   - expect(problem.messages).to eq({
128   - Digest::MD5.hexdigest(notice.message) => {'value' => notice.message, 'count' => 2}
129   - })
130   - end
131   -
132   - it 'inc stats hosts' do
133   - expect(problem.hosts).to eq({
134   - Digest::MD5.hexdigest(notice.host) => {'value' => notice.host, 'count' => 2}
135   - })
136   - end
137   -
138   - it 'inc stats user_agents' do
139   - expect(problem.user_agents).to eq({
140   - Digest::MD5.hexdigest(notice.user_agent_string) => {'value' => notice.user_agent_string, 'count' => 2}
141   - })
142   - end
143   - end
144   - end
145   -end
spec/models/error_report_spec.rb
... ... @@ -42,20 +42,6 @@ describe ErrorReport do
42 42 end
43 43 end
44 44  
45   - describe "#fingerprint_strategy" do
46   - it "should be possible to change how fingerprints are generated" do
47   - def error_report.fingerprint_strategy
48   - Class.new do
49   - def self.generate(*args)
50   - 'fingerprintzzz'
51   - end
52   - end
53   - end
54   -
55   - expect(error_report.error.fingerprint).to eq('fingerprintzzz')
56   - end
57   - end
58   -
59 45 describe "#generate_notice!" do
60 46 it "save a notice" do
61 47 expect {
... ...
spec/models/fingerprint/md5_spec.rb
... ... @@ -1,39 +0,0 @@
1   -describe Fingerprint::MD5, type: 'model' do
2   - context 'being created' do
3   - let(:backtrace) do
4   - Backtrace.find_or_create([
5   - {
6   - "number"=>"17",
7   - "file"=>"[GEM_ROOT]/gems/activesupport/lib/active_support/callbacks.rb",
8   - "method"=>"_run__2497084960985961383__process_action__2062871603614456254__callbacks"
9   - }
10   - ])
11   - end
12   - let(:notice1) { Fabricate.build(:notice, :backtrace => backtrace) }
13   - let(:notice2) { Fabricate.build(:notice, :backtrace => backtrace_2) }
14   -
15   - context "with same backtrace" do
16   - let(:backtrace_2) do
17   - new_lines = backtrace.lines.dup
18   - new_lines.last[:method] = '_run__FRAGMENT__process_action__FRAGMENT__callbacks'
19   - Backtrace.find_or_create(new_lines)
20   - end
21   -
22   - it "normalizes the fingerprint of generated methods" do
23   - expect(Fingerprint::MD5.generate(notice1, "api key")).to eql Fingerprint::MD5.generate(notice2, "api key")
24   - end
25   - end
26   -
27   - context "with same backtrace where FRAGMENT has not been extracted" do
28   - let(:backtrace_2) do
29   - new_lines = backtrace.lines.dup
30   - new_lines.last[:method] = '_run__998857585768765__process_action__1231231312321313__callbacks'
31   - Backtrace.find_or_create(new_lines)
32   - end
33   -
34   - it "normalizes the fingerprint of generated methods" do
35   - expect(Fingerprint::MD5.generate(notice1, "api key")).to eql Fingerprint::MD5.generate(notice2, "api key")
36   - end
37   - end
38   - end
39   -end
spec/models/fingerprint/sha1_spec.rb
... ... @@ -1,79 +0,0 @@
1   -describe Fingerprint::Sha1, type: 'model' do
2   - context '#generate' do
3   - let(:backtrace) {
4   - Backtrace.find_or_create([
5   - {"number"=>"425", "file"=>"[GEM_ROOT]/gems/activesupport-3.0.0.rc/lib/active_support/callbacks.rb", "method"=>"_run__2115867319__process_action__262109504__callbacks"},
6   - {"number"=>"404", "file"=>"[GEM_ROOT]/gems/activesupport-3.0.0.rc/lib/active_support/callbacks.rb", "method"=>"send"},
7   - {"number"=>"404", "file"=>"[GEM_ROOT]/gems/activesupport-3.0.0.rc/lib/active_support/callbacks.rb", "method"=>"_run_process_action_callbacks"}
8   - ])
9   - }
10   - let(:notice1) { Fabricate.build(:notice, :backtrace => backtrace) }
11   - let(:notice2) { Fabricate.build(:notice, :backtrace => backtrace_2) }
12   -
13   - context "with same backtrace" do
14   - let(:backtrace_2) { backtrace }
15   - it 'should create the same fingerprint for two notices' do
16   - expect(Fingerprint::Sha1.generate(notice1, "api key")).to eq Fingerprint::Sha1.generate(notice2, "api key")
17   - end
18   - end
19   -
20   - context "with different backtrace with only last line change" do
21   - let(:backtrace_2) {
22   - new_lines = backtrace.lines.dup
23   - new_lines.last[:number] = 401
24   - Backtrace.find_or_create backtrace.lines
25   - }
26   - it 'should not same fingerprint' do
27   - expect(
28   - Fingerprint::Sha1.generate(notice1, "api key")
29   - ).not_to eql Fingerprint::Sha1.generate(notice2, "api key")
30   - end
31   - end
32   -
33   - context 'with messages differing in object string memory addresses' do
34   - let(:backtrace_2) { backtrace }
35   -
36   - before do
37   - notice1.message = "NoMethodError: undefined method `foo' for #<ActiveSupport::HashWithIndifferentAccess:0x007f6bfe3287e8>"
38   - notice2.message = "NoMethodError: undefined method `foo' for #<ActiveSupport::HashWithIndifferentAccess:0x007f6bfd9f5338>"
39   - end
40   -
41   - its 'fingerprints should be equal' do
42   - expect(Fingerprint::Sha1.generate(notice1, 'api key')).to eq Fingerprint::Sha1.generate(notice2, 'api key')
43   - end
44   - end
45   -
46   - context 'with different messages at same stacktrace' do
47   - let(:backtrace_2) { backtrace }
48   -
49   - before do
50   - notice1.message = "NoMethodError: undefined method `bar' for #<ActiveSupport::HashWithIndifferentAccess:0x007f6bfe3287e8>"
51   - notice2.message = "NoMethodError: undefined method `bar' for nil:NilClass"
52   - end
53   -
54   - its 'fingerprints should not be equal' do
55   - expect(Fingerprint::Sha1.generate(notice1, 'api key')).to_not eq Fingerprint::Sha1.generate(notice2, 'api key')
56   - end
57   - end
58   - end
59   -
60   - describe '#unified_message' do
61   - subject{ Fingerprint::Sha1.new(double('notice', message: message), 'api key').unified_message }
62   -
63   - context "full error message" do
64   - let(:message) { "NoMethodError: undefined method `foo' for #<ActiveSupport::HashWithIndifferentAccess:0x007f6bfe3287e8>" }
65   -
66   - it 'removes memory address from object strings' do
67   - is_expected.to eq "NoMethodError: undefined method `foo' for #<ActiveSupport::HashWithIndifferentAccess>"
68   - end
69   - end
70   -
71   - context "multiple object strings in message" do
72   - let(:message) { "#<ActiveSupport::HashWithIndifferentAccess:0x007f6bfe3287e8> #<Object:0x007fa2b33d9458>" }
73   -
74   - it 'removes memory addresses globally' do
75   - is_expected.to eq "#<ActiveSupport::HashWithIndifferentAccess> #<Object>"
76   - end
77   - end
78   - end
79   -end
spec/models/notice_fingerprinter_spec.rb 0 → 100644
... ... @@ -0,0 +1,64 @@
  1 +describe NoticeFingerprinter, type: 'model' do
  2 + let(:fingerprinter) { described_class.new }
  3 + let(:notice) { Fabricate(:notice) }
  4 + let(:backtrace) { Fabricate(:backtrace) }
  5 +
  6 + context '#generate' do
  7 + it 'generates the same fingerprint for the same notice' do
  8 + f1 = fingerprinter.generate('123', notice, backtrace)
  9 + f2 = fingerprinter.generate('123', notice, backtrace)
  10 + expect(f1).to eq(f2)
  11 + end
  12 +
  13 + %w(error_class message component action environment_name).each do |i|
  14 + it "affects the fingerprint when #{i} is false" do
  15 + f1 = fingerprinter.generate('123', notice, backtrace)
  16 + f2 = fingerprinter.generate('123', notice, backtrace)
  17 +
  18 + fingerprinter.send((i << '=').to_sym, false)
  19 + f3 = fingerprinter.generate('123', notice, backtrace)
  20 +
  21 + expect(f1).to eq(f2)
  22 + expect(f1).to_not eq(f3)
  23 + end
  24 + end
  25 +
  26 + it 'affects the fingerprint with different backtrace_lines config' do
  27 + f1 = fingerprinter.generate('123', notice, backtrace)
  28 + f2 = fingerprinter.generate('123', notice, backtrace)
  29 +
  30 + fingerprinter.backtrace_lines = 2
  31 + f3 = fingerprinter.generate('123', notice, backtrace)
  32 +
  33 + expect(f1).to eq(f2)
  34 + expect(f1).to_not eq(f3)
  35 + end
  36 +
  37 + context 'two backtraces have the same first two lines' do
  38 + let(:backtrace1) { Fabricate(:backtrace) }
  39 + let(:backtrace2) { Fabricate(:backtrace) }
  40 +
  41 + before do
  42 + backtrace1.lines[0] = backtrace2.lines[0]
  43 + backtrace1.lines[1] = backtrace2.lines[1]
  44 + backtrace1.lines[2] = { number: 1, file: 'a', method: :b }
  45 + end
  46 +
  47 + it 'has the same fingerprint when only considering two lines' do
  48 + fingerprinter.backtrace_lines = 2
  49 + f1 = fingerprinter.generate('123', notice, backtrace1)
  50 + f2 = fingerprinter.generate('123', notice, backtrace2)
  51 +
  52 + expect(f1).to eq(f2)
  53 + end
  54 +
  55 + it 'has a different fingerprint when considering three lines' do
  56 + fingerprinter.backtrace_lines = 3
  57 + f1 = fingerprinter.generate('123', notice, backtrace1)
  58 + f2 = fingerprinter.generate('123', notice, backtrace2)
  59 +
  60 + expect(f1).to_not eq(f2)
  61 + end
  62 + end
  63 + end
  64 +end
... ...
spec/models/problem_spec.rb
... ... @@ -397,4 +397,100 @@ describe Problem, type: &#39;model&#39; do
397 397 end
398 398 end
399 399 end
  400 +
  401 + describe '#recache' do
  402 + let(:problem) { Fabricate(:problem_with_errs) }
  403 + let(:first_errs) { problem.errs }
  404 + let!(:notice) { Fabricate(:notice, :err => first_errs.first) }
  405 +
  406 + before do
  407 + problem.update_attribute(:notices_count, 0)
  408 + end
  409 +
  410 + it 'update the notice_count' do
  411 + expect {
  412 + problem.recache
  413 + }.to change{
  414 + problem.notices_count
  415 + }.from(0).to(1)
  416 + end
  417 +
  418 + context "with only one notice" do
  419 + before do
  420 + problem.update_attributes!(:messages => {})
  421 + problem.recache
  422 + end
  423 +
  424 + it 'update information about this notice' do
  425 + expect(problem.message).to eq notice.message
  426 + expect(problem.where).to eq notice.where
  427 + end
  428 +
  429 + it 'update first_notice_at' do
  430 + expect(problem.first_notice_at).to eq notice.reload.created_at
  431 + end
  432 +
  433 + it 'update last_notice_at' do
  434 + expect(problem.last_notice_at).to eq notice.reload.created_at
  435 + end
  436 +
  437 + it 'update stats messages' do
  438 + expect(problem.messages).to eq({
  439 + Digest::MD5.hexdigest(notice.message) => {'value' => notice.message, 'count' => 1}
  440 + })
  441 + end
  442 +
  443 + it 'update stats hosts' do
  444 + expect(problem.hosts).to eq({
  445 + Digest::MD5.hexdigest(notice.host) => {'value' => notice.host, 'count' => 1}
  446 + })
  447 + end
  448 +
  449 + it 'update stats user_agents' do
  450 + expect(problem.user_agents).to eq({
  451 + Digest::MD5.hexdigest(notice.user_agent_string) => {'value' => notice.user_agent_string, 'count' => 1}
  452 + })
  453 + end
  454 + end
  455 +
  456 + context "with several notices" do
  457 + let!(:notice_2) { Fabricate(:notice, :err => first_errs.first) }
  458 + let!(:notice_3) { Fabricate(:notice, :err => first_errs.first) }
  459 + before do
  460 + problem.update_attributes!(:messages => {})
  461 + problem.recache
  462 + end
  463 +
  464 + it 'update information about this notice' do
  465 + expect(problem.message).to eq notice.message
  466 + expect(problem.where).to eq notice.where
  467 + end
  468 +
  469 + it 'update first_notice_at' do
  470 + expect(problem.first_notice_at.to_i).to be_within(2).of(notice.created_at.to_i)
  471 + end
  472 +
  473 + it 'update last_notice_at' do
  474 + expect(problem.last_notice_at.to_i).to be_within(2).of(notice.created_at.to_i)
  475 + end
  476 +
  477 + it 'update stats messages' do
  478 + expect(problem.messages).to eq({
  479 + Digest::MD5.hexdigest(notice.message) => {'value' => notice.message, 'count' => 3}
  480 + })
  481 + end
  482 +
  483 + it 'update stats hosts' do
  484 + expect(problem.hosts).to eq({
  485 + Digest::MD5.hexdigest(notice.host) => {'value' => notice.host, 'count' => 3}
  486 + })
  487 + end
  488 +
  489 + it 'update stats user_agents' do
  490 + expect(problem.user_agents).to eq({
  491 + Digest::MD5.hexdigest(notice.user_agent_string) => {'value' => notice.user_agent_string, 'count' => 3}
  492 + })
  493 + end
  494 + end
  495 + end
400 496 end
... ...