snitch_reporting 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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