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.
- checksums.yaml +4 -4
- data/README.md +23 -3
- data/app/assets/javascripts/snitch_reporting/snitch_report.js +60 -0
- data/app/assets/stylesheets/snitch_reporting/_variables.scss +5 -0
- data/app/assets/stylesheets/snitch_reporting/components.scss +120 -0
- data/app/assets/stylesheets/snitch_reporting/containers.scss +6 -0
- data/app/assets/stylesheets/snitch_reporting/default.scss +18 -0
- data/app/assets/stylesheets/snitch_reporting/forms.scss +36 -0
- data/app/assets/stylesheets/snitch_reporting/navigation.scss +37 -0
- data/app/assets/stylesheets/snitch_reporting/snitch_report.scss +1 -0
- data/app/assets/stylesheets/snitch_reporting/tables.scss +127 -0
- data/app/controllers/snitch_reporting/application_controller.rb +4 -0
- data/app/controllers/snitch_reporting/snitch_reports_controller.rb +167 -0
- data/app/helpers/snitch_reporting/params_helper.rb +38 -0
- data/app/helpers/snitch_reporting/snitch_report_helper.rb +2 -0
- data/app/models/snitch_reporting/service/json_wrapper.rb +5 -0
- data/app/models/snitch_reporting/snitch_comment.rb +7 -0
- data/app/models/snitch_reporting/snitch_history.rb +7 -0
- data/app/models/snitch_reporting/snitch_occurrence.rb +183 -0
- data/app/models/snitch_reporting/snitch_report.rb +301 -0
- data/app/models/snitch_reporting/snitch_tracker.rb +17 -0
- data/app/views/snitch_reporting/snitch_reports/_filters.html.erb +16 -0
- data/app/views/snitch_reporting/snitch_reports/_navigation.html.erb +0 -0
- data/app/views/snitch_reporting/snitch_reports/edit.html.erb +19 -0
- data/app/views/snitch_reporting/snitch_reports/index.html.erb +75 -0
- data/app/views/snitch_reporting/snitch_reports/show.html.erb +189 -0
- data/config/routes.rb +4 -1
- data/lib/generators/snitch_reporting/install/install_generator.rb +24 -0
- data/lib/generators/snitch_reporting/install/templates/install_snitch_reporting.rb +62 -0
- data/lib/snitch_reporting/engine.rb +3 -0
- data/lib/snitch_reporting/rack.rb +29 -0
- data/lib/snitch_reporting/version.rb +1 -1
- data/lib/snitch_reporting.rb +3 -1
- metadata +58 -3
@@ -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,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
|