snitch_reporting 0.1.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +23 -3
  3. data/app/assets/javascripts/snitch_reporting/snitch_report.js +60 -0
  4. data/app/assets/stylesheets/snitch_reporting/_variables.scss +5 -0
  5. data/app/assets/stylesheets/snitch_reporting/components.scss +120 -0
  6. data/app/assets/stylesheets/snitch_reporting/containers.scss +6 -0
  7. data/app/assets/stylesheets/snitch_reporting/default.scss +18 -0
  8. data/app/assets/stylesheets/snitch_reporting/forms.scss +36 -0
  9. data/app/assets/stylesheets/snitch_reporting/navigation.scss +37 -0
  10. data/app/assets/stylesheets/snitch_reporting/snitch_report.scss +1 -0
  11. data/app/assets/stylesheets/snitch_reporting/tables.scss +127 -0
  12. data/app/controllers/snitch_reporting/application_controller.rb +4 -0
  13. data/app/controllers/snitch_reporting/snitch_reports_controller.rb +167 -0
  14. data/app/helpers/snitch_reporting/params_helper.rb +38 -0
  15. data/app/helpers/snitch_reporting/snitch_report_helper.rb +2 -0
  16. data/app/models/snitch_reporting/service/json_wrapper.rb +5 -0
  17. data/app/models/snitch_reporting/snitch_comment.rb +7 -0
  18. data/app/models/snitch_reporting/snitch_history.rb +7 -0
  19. data/app/models/snitch_reporting/snitch_occurrence.rb +183 -0
  20. data/app/models/snitch_reporting/snitch_report.rb +301 -0
  21. data/app/models/snitch_reporting/snitch_tracker.rb +17 -0
  22. data/app/views/snitch_reporting/snitch_reports/_filters.html.erb +16 -0
  23. data/app/views/snitch_reporting/snitch_reports/_navigation.html.erb +0 -0
  24. data/app/views/snitch_reporting/snitch_reports/edit.html.erb +19 -0
  25. data/app/views/snitch_reporting/snitch_reports/index.html.erb +75 -0
  26. data/app/views/snitch_reporting/snitch_reports/show.html.erb +189 -0
  27. data/config/routes.rb +4 -1
  28. data/lib/generators/snitch_reporting/install/install_generator.rb +24 -0
  29. data/lib/generators/snitch_reporting/install/templates/install_snitch_reporting.rb +62 -0
  30. data/lib/snitch_reporting/engine.rb +3 -0
  31. data/lib/snitch_reporting/rack.rb +29 -0
  32. data/lib/snitch_reporting/version.rb +1 -1
  33. data/lib/snitch_reporting.rb +3 -1
  34. metadata +58 -3
@@ -0,0 +1,4 @@
1
+ module SnitchReporting
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,167 @@
1
+ require_dependency "snitch_reporting/application_controller"
2
+
3
+ class ::SnitchReporting::SnitchReportsController < ApplicationController
4
+ include ::SnitchReporting::ParamsHelper
5
+ helper ::SnitchReporting::SnitchReportHelper
6
+
7
+ layout "application"
8
+
9
+ def index
10
+ @reports = ::SnitchReporting::SnitchReport.order("last_occurrence_at DESC NULLS LAST").page(params[:page]).per(params[:per] || 25)
11
+
12
+ # set_report_preferences
13
+ filter_reports
14
+ # sort_reports
15
+ end
16
+
17
+ def show
18
+ @report = ::SnitchReporting::SnitchReport.find(params[:id])
19
+ occurrences = @report.occurrences.order(created_at: :asc)
20
+ @occurrence = occurrences.find_by(id: params[:occurrence]) || occurrences.last
21
+ occurrence_ids = occurrences.ids
22
+ occurrence_idx = occurrence_ids.index(@occurrence.id)
23
+ @paged_ids = {
24
+ first: occurrence_idx == 0 ? nil : occurrence_ids.first,
25
+ prev: occurrence_idx == 0 ? nil : occurrence_ids[occurrence_idx - 1],
26
+ next: occurrence_idx == occurrence_ids.length - 1 ? nil : occurrence_ids[occurrence_idx + 1],
27
+ last: occurrence_idx == occurrence_ids.length - 1 ? nil : occurrence_ids.last,
28
+ }
29
+ # @formatted_occurrence_data = occurrences.staggered_occurrence_data
30
+ # @comments = @report.comments.order(created_at: :desc)
31
+ end
32
+
33
+
34
+ def update
35
+ @report = ::SnitchReporting::SnitchReport.find(params[:id])
36
+ # @report.acting_user = current_user
37
+ @report.update(report_params)
38
+
39
+ respond_to do |format|
40
+ format.html { redirect_to @report }
41
+ format.json
42
+ end
43
+ end
44
+ #
45
+ # def comment
46
+ # if @report.comments.create(comment_params.merge(author: current_credential).merge(params.permit(:resolved, :ignored)))
47
+ # update_report_for(:resolved, comment: false)
48
+ # update_report_for(:ignored, comment: false)
49
+ #
50
+ # redirect_to snitch_report_path(@report)
51
+ # else
52
+ # redirect_to snitch_report_path(@report), alert: "Failed to comment. Please try again."
53
+ # end
54
+ # end
55
+ #
56
+ private
57
+
58
+ def report_params
59
+ params.require(:snitch_report).permit(
60
+ :ignored,
61
+ :resolved
62
+ )
63
+ end
64
+ #
65
+ # def current_snitch_report
66
+ # @report = SnitchReporting::SnitchReport.find(params[:id])
67
+ # end
68
+ #
69
+ # def update_report_for(status, comment: true)
70
+ # raise "Value not allowed: #{status}" unless status.in?([:resolved, :ignored])
71
+ #
72
+ # if true_param?(status)
73
+ # @report.update("#{status}_at": Time.current, "#{status}_by": current_credential)
74
+ # # @report.comments.create(author: current_credential, message: ">>> Marked as #{status} <<<", skip_notify: true, status => true) if comment
75
+ # elsif params[status].present?
76
+ # # @report.comments.create(author: current_credential, message: ">>> Marked as un#{status} <<<", skip_notify: true, status => false) if comment
77
+ # @report.update("#{status}_at": nil, "#{status}_by": nil)
78
+ # end
79
+ # end
80
+ #
81
+ # def report_params
82
+ # params.require(:snitch_report).permit(
83
+ # :source,
84
+ # :severity,
85
+ # :assigned_to_id,
86
+ # :title,
87
+ # :custom_details
88
+ # )
89
+ # end
90
+ #
91
+ # def comment_params
92
+ # params.require(:snitch_comment).permit(:message)
93
+ # end
94
+ #
95
+ # def set_report_preferences
96
+ # @report_preferences = begin
97
+ # preferences = JSON.parse(session[:report_preferences].presence || "{}").symbolize_keys
98
+ #
99
+ # available_preferences = [:level_tags, :severity_tags, :source_tags, :resolved, :ignored]
100
+ # available_preferences.each do |pref_key|
101
+ # pref_val = params[pref_key]
102
+ # preferences[pref_key] = pref_val if pref_val.present?
103
+ # preferences.delete(pref_key) if pref_val == "all"
104
+ # end
105
+ #
106
+ # session[:report_preferences] = preferences.to_json
107
+ # preferences
108
+ # end
109
+ # end
110
+ def set_filters
111
+ @filter_sets = {
112
+ status: {
113
+ default: :unresolved,
114
+ values: [:all, :resolved, :unresolved]
115
+ },
116
+ # assignee: {
117
+ # default: :any,
118
+ # values: [:any, :me, :not_me, :not_assigned]
119
+ # },
120
+ # log_level: {
121
+ # default: :any,
122
+ # values: [:any] + ::SnitchReporting::SnitchReport.log_levels.keys.map(&:to_sym)
123
+ # },
124
+ # ignored: {
125
+ # default: :not_ignored,
126
+ # values: [:all, :ignored, :not_ignored]
127
+ # }
128
+ }
129
+
130
+ @filters = @filter_sets.each_with_object({set_filters: {}}) do |(filter_name, filter_set), filters|
131
+ filters[filter_name] = filter_set[:default]
132
+ filter_in_param = params[filter_name].try(:to_sym)
133
+ next unless filter_in_param && filter_set[:values].include?(filter_in_param)
134
+ filters[filter_name] = filter_in_param
135
+ filters[:set_filters][filter_name] = filter_in_param
136
+ end
137
+ end
138
+
139
+ def filter_reports
140
+ set_filters
141
+
142
+ @reports = @reports.resolved if @filters[:status] == :resolved
143
+ @reports = @reports.unresolved if @filters[:status] == :unresolved
144
+ # @reports = @reports.search(@report_preferences[:by_fuzzy_text]) if @report_preferences[:by_fuzzy_text].present?
145
+ #
146
+ # @reports = @reports.by_level(@report_preferences[:level_tags]) if @report_preferences[:level_tags].present?
147
+ # @reports = @reports.by_severity(@report_preferences[:severity_tags]) if @report_preferences[:severity_tags].present?
148
+ # @reports = @reports.by_source(@report_preferences[:source_tags]) if @report_preferences[:source_tags].present?
149
+ #
150
+ # @reports = @report_preferences[:resolved].present? && truthy?(@report_preferences[:resolved]) ? @reports.resolved : @reports.unresolved
151
+ # @reports = @report_preferences[:ignored].present? && truthy?(@report_preferences[:ignored]) ? @reports.ignored : @reports.unignored
152
+ end
153
+ #
154
+ # def sort_reports
155
+ # order = sort_order || "desc"
156
+ # @reports =
157
+ # case params[:sort]
158
+ # when "count"
159
+ # @reports.order("snitch_reporting_snitch_reports.occurrence_count #{order} NULLS LAST, snitch_reporting_snitch_reports.last_occurrence_at DESC NULLS LAST")
160
+ # when "last"
161
+ # @reports.order("snitch_reporting_snitch_reports.last_occurrence_at #{order} NULLS LAST")
162
+ # else
163
+ # @reports.order("snitch_reporting_snitch_reports.last_occurrence_at DESC NULLS LAST")
164
+ # end
165
+ # @reports = @reports.page(params[:page]).per(params[:per])
166
+ # end
167
+ end
@@ -0,0 +1,38 @@
1
+ module ::SnitchReporting::ParamsHelper
2
+ def sort_order
3
+ params[:order] == "desc" ? "desc" : "asc"
4
+ end
5
+
6
+ def toggled_sort_order
7
+ params[:order] == "desc" ? "asc" : "desc"
8
+ end
9
+
10
+ def truthy?(val)
11
+ val.to_s.downcase.in?(["true", "t", "1"])
12
+ end
13
+
14
+ def true_param?(*param_keys)
15
+ truthy?(params&.dig(*param_keys))
16
+ end
17
+
18
+ def svg(img, opts={})
19
+ ""
20
+ end
21
+
22
+ def sortable(link_text, param_string, options={})
23
+ if param_string.nil?
24
+ return link_to link_text.html_safe, options[:additional_params] || {}, class: "sortable #{options[:class]}" # rubocop:disable Rails/OutputSafety - We're certain this is safe because we're in control of all of the elements.
25
+ end
26
+ if params[:sort] == param_string
27
+ sorted_class = "sorted sortable-#{toggled_sort_order}"
28
+ default_order = toggled_sort_order
29
+ end
30
+
31
+ additional_params = options[:additional_params] || {}
32
+ additional_params = additional_params.permit!.to_h if additional_params.is_a?(ActionController::Parameters)
33
+ # rubocop:disable Rails/OutputSafety - We're certain this is safe because we're in control of all of the elements.
34
+ link_html = "#{link_text}<div class=\"sortable-arrows\">#{svg('icons/UpChevron.svg', class: 'desc')}#{svg('icons/DownChevron.svg', class: 'asc')}</div>".html_safe
35
+ # rubocop:enable Rails/OutputSafety
36
+ link_to link_html, { sort: param_string, order: default_order || "desc" }.reverse_merge(additional_params), class: "sortable #{sorted_class} #{options[:class]}"
37
+ end
38
+ end
@@ -0,0 +1,2 @@
1
+ module ::SnitchReporting::SnitchReportHelper
2
+ end
@@ -0,0 +1,5 @@
1
+ class ::SnitchReporting::Service::JSONWrapper
2
+ # Allows directly setting pre-stringified JSON.
3
+ def self.dump(obj); obj.is_a?(String) ? obj : JSON.dump(obj); end
4
+ def self.load(str); str.present? ? JSON.parse(str) : str; end
5
+ end
@@ -0,0 +1,7 @@
1
+ # belongs_to :snitch_report
2
+ # belongs_to :author
3
+ # text :body
4
+ class SnitchReporting::SnitchComment < ApplicationRecord
5
+ belongs_to :report, class_name: "SnitchReporting::SnitchReport"
6
+ belongs_to :author, optional: true
7
+ end
@@ -0,0 +1,7 @@
1
+ # belongs_to :snitch_report
2
+ # belongs_to :user
3
+ # text :text
4
+ class SnitchReporting::SnitchHistory < ApplicationRecord
5
+ belongs_to :report, class_name: "SnitchReporting::SnitchReport"
6
+ belongs_to :user, optional: true
7
+ end
@@ -0,0 +1,183 @@
1
+ # belongs_to :snitch_report
2
+ # string :http_method
3
+ # string :url
4
+ # text :user_agent
5
+ # text :backtrace
6
+ # text :context
7
+ # text :params
8
+ # text :headers
9
+
10
+ class SnitchReporting::SnitchOccurrence < ApplicationRecord
11
+ attr_accessor :always_notify, :acting_user, :should_notify
12
+
13
+ belongs_to :report, class_name: "SnitchReporting::SnitchReport"
14
+
15
+ after_create :mark_occurrence
16
+
17
+ serialize :backtrace_data, ::SnitchReporting::Service::JSONWrapper
18
+ serialize :backtrace, ::SnitchReporting::Service::JSONWrapper
19
+ serialize :context, ::SnitchReporting::Service::JSONWrapper
20
+ serialize :params, ::SnitchReporting::Service::JSONWrapper
21
+ serialize :headers, ::SnitchReporting::Service::JSONWrapper
22
+
23
+ # def self.staggered_occurrence_data
24
+ # data = {}
25
+ # occurrence_data_keys = all.map { |occurrence| occurrence.details.try(:keys) }.compact
26
+ # longest_key_array = occurrence_data_keys.max_by(&:length) # Need the longest because `zip` removes any objects longer than the initial.
27
+ # staggered_keys = longest_key_array&.zip(*occurrence_data_keys)&.flatten(1)&.compact&.uniq || []
28
+ #
29
+ # data[:keys] = staggered_keys
30
+ # data[:details] = all.map do |occurrence|
31
+ # detail_hash = {
32
+ # id: occurrence.id,
33
+ # details: []
34
+ # }
35
+ # occurrence_details = occurrence.details
36
+ # data[:keys].each_with_index do |detail_key, idx|
37
+ # occurrence_detail = occurrence_details[detail_key]
38
+ # detail_hash[:details][idx] =
39
+ # if occurrence_detail.is_a?(Array)
40
+ # occurrence_detail.join("\n")
41
+ # else
42
+ # occurrence_detail.to_s
43
+ # end
44
+ # end
45
+ # detail_hash
46
+ # end
47
+ #
48
+ # data
49
+ # end
50
+ #
51
+ # def from_data=(details_hash)
52
+ # self.details = flatten_hash(details_hash)
53
+ # end
54
+
55
+ def backtrace=(trace_lines)
56
+ already_traced = []
57
+ self.backtrace_data = trace_lines.map do |trace_line|
58
+ next unless trace_line.include?("/app/") # && trace_line.exclude?("app/models/snitch_reporting")
59
+
60
+ joined_path = file_lines_from_backtrace(trace_line)
61
+ next if joined_path.include?(joined_path)
62
+ already_traced << joined_path
63
+
64
+ file_path, line_number = joined_path.split(":", 2)
65
+ {
66
+ file_path: remove_project_root(file_path),
67
+ line_number: line_number,
68
+ snippet: snippet_for_line(trace_line)
69
+ }
70
+ end.compact
71
+ super(trace_lines.map { |trace_line| remove_project_root(trace_line) })
72
+ end
73
+
74
+ def filtered_backtrace
75
+ backtrace_data&.map { |data| data&.dig(:file_path) }.compact
76
+ end
77
+
78
+ def remove_project_root(row)
79
+ row.gsub(Rails.root.to_s, "[PROJECT_ROOT]")
80
+ end
81
+
82
+ def file_lines_from_backtrace(backtrace_line)
83
+ backtrace_line[/(\/?\w+\/?)+(\.\w+)+:?\d*/]
84
+ end
85
+
86
+ def snippet_for_line(backtrace_line, line_count: 5)
87
+ file, num = backtrace_line.split(":")
88
+ first_line_number = num.to_i
89
+ offset_line_numbers = (line_count - 1) / 2
90
+ line_numbers = (first_line_number - offset_line_numbers - 1)..(first_line_number + offset_line_numbers - 1)
91
+
92
+ lines = File.open(file).read.split("\n").map.with_index { |line, idx| [line, idx + 1] }[line_numbers] rescue nil
93
+
94
+ whitespace_count = lines.map { |line, _idx| line[/\s*/].length }.min
95
+
96
+ lines.each_with_object({}) do |(line, idx), data|
97
+ data[idx] = line[whitespace_count..-1]
98
+ end
99
+ end
100
+ #
101
+ # def title
102
+ # super.presence || "#{report.title} | Occurrence ##{id}"
103
+ # end
104
+ #
105
+ # def details
106
+ # temp_details = super
107
+ #
108
+ # temp_details.each do |detail_key, detail_val|
109
+ # next unless detail_val.to_s.first.in?(["[", "{"])
110
+ #
111
+ # begin
112
+ # temp_details[detail_key] = JSON.parse(detail_val)
113
+ # rescue JSON::ParserError
114
+ # nil
115
+ # end
116
+ # end
117
+ #
118
+ # temp_details
119
+ # end
120
+
121
+ def notify?
122
+ @should_notify ||= always_notify || report.resolved? || report.occurrence_count == 1
123
+ end
124
+
125
+ private
126
+
127
+ def mark_occurrence
128
+ notify? # Store ivar
129
+ report_updates = { last_occurrence_at: Time.current, resolved_at: nil }
130
+ report_updates[:first_occurrence_at] = Time.current if report.first_occurrence_at.nil?
131
+ report_updates[:occurrence_count] = report.occurrence_count.to_i + 1
132
+
133
+ report.update(report_updates)
134
+
135
+ tracker = report.tracker_for_date
136
+ now = Time.current
137
+ todays_occurrences = report.occurrences.where(created_at: now.beginning_of_day..now.end_of_day)
138
+ tracker.update(count: todays_occurrences.count + 1)
139
+ end
140
+
141
+ # def to_slack_attachment
142
+ # details ||= {}
143
+ # description = details[:description] || details[:error_description] || details[:error] || details[:explanation]
144
+ # source = report.source.to_s.titleize
145
+ # level = report.log_level.to_sym
146
+ #
147
+ # color =
148
+ # case level
149
+ # when :debug then "#F5F5F5"
150
+ # when :info then "#209CEE"
151
+ # when :warn then "#FFDD57"
152
+ # when :error then "#FF945B"
153
+ # when :fatal then "#FF1F35"
154
+ # when :unknown then "#000000"
155
+ # end
156
+ # fields = []
157
+ # fields << { title: "Level", value: level, short: true } if level.present?
158
+ # fields << { title: "Source", value: source, short: true } if source.present?
159
+ # fields << { title: "Description", value: description, short: false } if description.present?
160
+ #
161
+ # {
162
+ # pretext: report.resolved? ? "A previously resolved #{level} level error has occurred again:" : "A new #{level} level error has occurred:",
163
+ # title: title,
164
+ # fallback: "```#{filtered_backtrace.present? ? filtered_backtrace.first(5).join("\n") : description}```",
165
+ # color: color,
166
+ # title_link: custom_url_for(:helix, :bug_report_path, report_id),
167
+ # text: filtered_backtrace.present? ? filtered_backtrace.first(10).join("\n") : description,
168
+ # fields: fields,
169
+ # footer: "Last occurred:",
170
+ # ts: created_at.to_i
171
+ # }
172
+ # end
173
+ #
174
+ # def flatten_hash(hash_to_flatten, hash_memo={}, ancestor_key_str=nil)
175
+ # hash_to_flatten = hash_to_flatten[0] if hash_to_flatten.is_a?(Array) && hash_to_flatten.first.is_a?(Hash)
176
+ # return hash_memo.merge!(ancestor_key_str => hash_to_flatten.to_s) unless hash_to_flatten.is_a?(Hash)
177
+ # # If the current object is not a hash, we don't need to continue nesting, so set the current ancestor string as the final key.
178
+ # hash_to_flatten.each { |key, hash_values| flatten_hash(hash_values, hash_memo, [ancestor_key_str, key].compact.join(".")) }
179
+ # # Iterate through the hash, and pass each key as the ancestor into the current method to flatten lower level hashes.
180
+ # hash_memo
181
+ # # Return the current hash for memoization.
182
+ # end
183
+ end
@@ -0,0 +1,301 @@
1
+ # text :error
2
+ # text :message
3
+ # integer :log_level
4
+ # string :klass
5
+ # string :action
6
+ # text :tags
7
+ # datetime :first_occurrence_at
8
+ # datetime :last_occurrence_at
9
+ # bigint :occurrence_count
10
+ # belongs_to :assigned_to
11
+ # datetime :resolved_at
12
+ # belongs_to :resolved_by
13
+ # datetime :ignored_at
14
+ # belongs_to :ignored_by
15
+
16
+ class SnitchReporting::SnitchReport < ApplicationRecord
17
+ attr_accessor :acting_user
18
+ has_many :occurrences, class_name: "SnitchReporting::SnitchOccurrence", foreign_key: :report_id
19
+ has_many :trackers, class_name: "SnitchReporting::SnitchTracker", foreign_key: :report_id
20
+ has_many :histories, class_name: "SnitchReporting::SnitchHistory", foreign_key: :report_id
21
+
22
+ # belongs_to :assigned_to
23
+ def assigned_to; end
24
+ # belongs_to :resolved_by
25
+ # belongs_to :ignored_by
26
+
27
+ scope :resolved, -> { where.not(resolved_at: nil) }
28
+ scope :unresolved, -> { where(resolved_at: nil) }
29
+ scope :ignored, -> { where.not(ignored_at: nil) }
30
+ scope :unignored, -> { where(ignored_at: nil) }
31
+ scope :by_level, ->(*level_tags) { where(log_level: level_tags) }
32
+
33
+ enum log_level: {
34
+ debug: 1,
35
+ info: 2,
36
+ warn: 3,
37
+ error: 4,
38
+ fatal: 5,
39
+ unknown: 6
40
+ }
41
+
42
+ class << self
43
+ def debug(*args); report(:debug, args); end
44
+ def info(*args); report(:info, args); end
45
+ def warn(*args); report(:warn, args); end
46
+ def error(*args); report(:error, args); end
47
+ def fatal(*args); report(:fatal, args); end
48
+ def unknown(*args); report(:unknown, args); end
49
+
50
+ def report(log_level, *args)
51
+ exceptions, arg_hash, arg_values = format_args(args)
52
+ env, klass, base_exception = extract_base_variables(exceptions, arg_hash, arg_values)
53
+ always_notify = arg_hash.delete(:always_notify)
54
+
55
+ report_title = retrieve_report_title(base_exception, arg_hash)
56
+ report = retrieve_or_create_existing_report(log_level, santize_title(report_title), env, base_exception, arg_hash)
57
+ return SnitchReporting::SnitchReport.error("Failed to save report.", report.errors.full_messages) unless report.persisted?
58
+
59
+ report_data = gather_report_data(env, exceptions, arg_hash, arg_values)
60
+
61
+ occurrence = report.occurrences.create(
62
+ http_method: env[:REQUEST_METHOD],
63
+ url: env[:REQUEST_URI],
64
+ user_agent: env[:HTTP_USER_AGENT],
65
+ backtrace: trace_from_exception(base_exception),
66
+ context: report_data,
67
+ params: env&.dig(:"action_controller.instance")&.params&.permit!&.to_h&.except(:action, :controller),
68
+ headers: env&.reject { |k, v| k.to_s.include?(".") || !v.is_a?(String) },
69
+ always_notify: always_notify
70
+ )
71
+ return SnitchReporting::SnitchReport.error("Failed to save occurrence.", occurrence.errors.full_messages) unless occurrence.persisted?
72
+ occurrence
73
+ rescue StandardError => ex
74
+ env ||= {}
75
+ SnitchReporting::SnitchReport.fatal("Failed to create report. (#{ex.class})", env, ex)
76
+ end
77
+
78
+ def format_args(args)
79
+ args = [args].flatten.compact
80
+
81
+ exceptions = args.select { |arg| arg.is_a?(Exception) }
82
+ reduced_arrays = args.select { |arg| arg.is_a?(Array) }.reduce([], :concat)
83
+ reduced_values = args.select { |arg| [Array, Exception, Hash].all? { |klass| !arg.is_a?(klass) } }
84
+ arg_values = reduced_arrays + reduced_values
85
+
86
+ arg_hash = (args.select { |arg| arg.is_a?(Hash) }.inject(&:merge) || {}).deep_symbolize_keys
87
+ # This will remove duplicate keys in the case there are multiple hashes
88
+ # passed in for some reason. I don't foresee this being an issue, but
89
+ # if it ever proves to be, this is the spot to refactor.
90
+ [exceptions, arg_hash, arg_values]
91
+ end
92
+
93
+ def extract_base_variables(exceptions, arg_hash, _arg_values)
94
+ exceptions << arg_hash.delete(:exception) if arg_hash[:exception].present?
95
+ base_exception = exceptions.first || {} # TODO: Deal with other exceptions here
96
+ klass = arg_hash[:klass] || arg_hash[:class]
97
+ env = arg_hash.delete(:env) || {}
98
+
99
+ [env, klass, base_exception]
100
+ end
101
+
102
+ def trace_from_exception(exception)
103
+ trace = exception.try(:backtrace).presence
104
+ return trace if trace.present?
105
+ trace = caller.dup
106
+ trace.shift until trace.first.exclude?("snitch_reporting")
107
+ trace
108
+ end
109
+
110
+ def extract_relevant_ivars(report_data, kontroller)
111
+ set_user_vars_from_source(report_data, kontroller)
112
+ end
113
+
114
+ def set_user_vars_from_source(report_data, source)
115
+ user_vars = source.try(:instance_variables)&.select { |ivar_key| ivar_key.to_s.starts_with?("@current_") } || []
116
+ user_vars.each do |user_var|
117
+ begin
118
+ ivar = source.instance_variable_get(user_var)
119
+ next if ivar.blank?
120
+
121
+ report_data[user_var] = get_details_from_ivar(ivar)
122
+ rescue StandardError => ex
123
+ report_data[user_var] = "!-- Failed to retrieve data from user #{user_var}: #{ivar.try(:class).try(:name)} (#{ex.class}) --!"
124
+ end
125
+ end
126
+ end
127
+
128
+ def get_details_from_ivar(ivar)
129
+ return ivar.class.name if ivar.class.name.include?("Abilit")
130
+ case ivar.class.name
131
+ when "String", "Array", "Hash" then ivar
132
+ when "ActionController::Parameters" then ivar.to_json
133
+ when "ActiveRecord::Relation"
134
+ "#{ivar.klass} ids: [#{ivar.pluck(:id).join(', ')}]"
135
+ else
136
+ ivar.try(:to_data) || ivar.try(:to_json) || ivar.to_s.presence
137
+ end
138
+ end
139
+
140
+ def add_controller_data_to_report(report_data, env)
141
+ kontroller = env&.dig(:"action_controller.instance")
142
+ return if kontroller.blank?
143
+
144
+ extract_relevant_ivars(report_data, kontroller)
145
+
146
+ kontroller.instance_variables.each do |ivar_key|
147
+ begin
148
+ next if ivar_key.to_s.starts_with?("@current_") # Already extracted these in the above method
149
+ next if ivar_key.in?(ignored_kontroller_ivars)
150
+
151
+ ivar = kontroller.instance_variable_get(ivar_key)
152
+ next if ivar.blank?
153
+
154
+ report_data[ivar_key] = get_details_from_ivar(ivar)
155
+ rescue StandardError => ex
156
+ report_data[ivar_key] = "!-- Failed to retrieve data from variable #{ivar_key}: #{ivar.try(:class).try(:name)} (#{ex.class}) --!"
157
+ end
158
+ end
159
+ end
160
+
161
+ def retrieve_report_title(exception, arg_hash)
162
+ report_title = arg_hash[:title].presence
163
+ report_title ||= exception.try(:message).presence
164
+ report_title ||= exception.try(:body).presence
165
+ report_title ||= exception.try(:class).presence
166
+ report_title ||= (arg_hash[:klass] || arg_hash[:class]).presence
167
+ report_title ||= trace_from_exception(exception).find { |row| row.include?("/app/") }
168
+ report_title
169
+ end
170
+
171
+ def add_leftover_objects_to_report_data(report_data, exceptions, arg_hash, arg_values)
172
+ report_data[:exceptions] = exceptions.map { |ex| "#{ex.try(:class)}: #{ex.try(:message)}" } if exceptions.present?
173
+ report_data.merge!(arg_hash)
174
+ report_data[:details] = arg_values if arg_values.present?
175
+ end
176
+
177
+ def add_sanitized_env_information_to_report_data(report_data, env)
178
+ report_data[:env] = env.slice(*relevant_env_keys) if env.present?
179
+ end
180
+
181
+ def santize_title(report_title)
182
+ regex_find_numbers_and_words_with_numbers = /\w*\d[\d\w]*/
183
+ # We remove all numbers and words that have numbers in them so that we can
184
+ # more easily group similar errors together, but often times errors have
185
+ # unique ids, so we strip those out.
186
+ report_title.to_s.gsub(regex_find_numbers_and_words_with_numbers, "").presence
187
+ end
188
+
189
+ def retrieve_or_create_existing_report(log_level, sanitized_title, env, exception, arg_hash)
190
+ report_identifiable_data = {
191
+ error: (exception.try(:class) || sanitized_title.presence).to_s,
192
+ message: sanitized_title.presence,
193
+ log_level: log_level,
194
+ klass: env&.dig(:"action_controller.instance").try(:class).to_s.split("::").last&.gsub("Controller", ""),
195
+ action: env&.dig(:"action_controller.instance").try(:action_name)
196
+ }
197
+
198
+ report = find_by(report_identifiable_data) if sanitized_title.present?
199
+ # Not using find or create because the slug might be `nil`- in these
200
+ # cases, we want to create a new report so that we don't falsely group
201
+ # unrelated errors together.
202
+ report || create(report_identifiable_data)
203
+ end
204
+
205
+ def gather_report_data(env, exceptions, arg_hash, arg_values)
206
+ report_data = {}
207
+
208
+ add_controller_data_to_report(report_data, env)
209
+ add_leftover_objects_to_report_data(report_data, exceptions, arg_hash, arg_values)
210
+ add_sanitized_env_information_to_report_data(report_data, env)
211
+
212
+ report_data
213
+ end
214
+
215
+ def ignored_kontroller_ivars
216
+ [
217
+ :@_request,
218
+ :@_response,
219
+ :@_response,
220
+ :@_lookup_context,
221
+ :@_authorized,
222
+ :@_main_app,
223
+ :@_view_renderer,
224
+ :@_view_context_class,
225
+ :@_db_runtime,
226
+ :@marked_for_same_origin_verification
227
+ ]
228
+ end
229
+
230
+ def relevant_env_keys
231
+ [
232
+ :REQUEST_URI,
233
+ :REQUEST_METHOD,
234
+ :HTTP_REFERER,
235
+ :HTTP_USER_AGENT,
236
+ :PATH_INFO,
237
+ :HTTP_CONNECTION,
238
+ :REMOTE_USER,
239
+ :SERVER_NAME,
240
+ :QUERY_STRING,
241
+ :REMOTE_HOST,
242
+ :SERVER_PORT,
243
+ :HTTP_ACCEPT_ENCODING,
244
+ :HTTP_USER_AGENT,
245
+ :SERVER_PROTOCOL,
246
+ :HTTP_CACHE_CONTROL,
247
+ :HTTP_ACCEPT_LANGUAGE,
248
+ :HTTP_HOST,
249
+ :REMOTE_ADDR,
250
+ :SERVER_SOFTWARE,
251
+ :HTTP_KEEP_ALIVE,
252
+ :HTTP_REFERER,
253
+ :HTTP_ACCEPT_CHARSET,
254
+ :GATEWAY_INTERFACE,
255
+ :HTTP_ACCEPT,
256
+ :HTTP_COOKIE
257
+ ]
258
+ end
259
+ end
260
+
261
+ def tracker_for_date(date=Date.today)
262
+ trackers.tracker_for_date(date)
263
+ end
264
+
265
+ def resolved?; resolved_at?; end
266
+ def ignored?; ignored_at?; end
267
+
268
+ def ignored=(bool)
269
+ if bool.to_s.downcase.in?(["t", "true", "1", "y", "yes"])
270
+ self.ignored_at ||= Time.current
271
+ # self.ignored_by ||= acting_user
272
+ else
273
+ self.ignored_at = nil
274
+ # self.ignored_by = nil
275
+ end
276
+ end
277
+
278
+ def resolved=(bool)
279
+ if bool.to_s.downcase.in?(["t", "true", "1", "y", "yes"])
280
+ self.resolved_at ||= Time.current
281
+ # self.resolved_by ||= acting_user
282
+ else
283
+ self.resolved_at = nil
284
+ # self.resolved_by = nil
285
+ end
286
+ end
287
+
288
+ # def title
289
+ # super.presence || slug.presence || "Report ##{id}"
290
+ # end
291
+ #
292
+ # def timeago
293
+ # "Not implemented"
294
+ # # timeago_in_words(last_occurrence_at)
295
+ # end
296
+ #
297
+ # def slack_channel
298
+ # "Not implemented"
299
+ # # "error-reporting"
300
+ # end
301
+ end