231 lines
7.2 KiB
Ruby
231 lines
7.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2011 - present Instructure, Inc.
|
|
#
|
|
# This file is part of Canvas.
|
|
#
|
|
# Canvas is free software: you can redistribute it and/or modify it under
|
|
# the terms of the GNU Affero General Public License as published by the Free
|
|
# Software Foundation, version 3 of the License.
|
|
#
|
|
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along
|
|
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
|
|
class ErrorReport < ActiveRecord::Base
|
|
belongs_to :user
|
|
belongs_to :account
|
|
serialize :http_env
|
|
# misc key/value pairs with more details on the error
|
|
serialize :data, Hash
|
|
|
|
before_save :guess_email
|
|
before_save :truncate_enormous_fields
|
|
|
|
# Define a custom callback for external notification of an error report.
|
|
define_callbacks :on_send_to_external
|
|
|
|
def send_to_external
|
|
run_callbacks(:on_send_to_external)
|
|
end
|
|
|
|
def truncate_enormous_fields
|
|
self.message = message.truncate(1024, omission: '...<truncated>') if message
|
|
data['exception_message'] = data['exception_message'].truncate(1024, omission: '...<truncated>') if data['exception_message']
|
|
end
|
|
|
|
class Reporter
|
|
|
|
IGNORED_CATEGORIES = "404,ActionDispatch::RemoteIp::IpSpoofAttackError,Turnitin::Errors::SubmissionNotScoredError".freeze
|
|
|
|
def ignored_categories
|
|
Setting.get('ignored_error_report_categories', IGNORED_CATEGORIES).split(',')
|
|
end
|
|
|
|
include ActiveSupport::Callbacks
|
|
define_callbacks :on_log_error
|
|
|
|
attr_reader :opts, :exception
|
|
|
|
def self.hostname
|
|
@cached_hostname ||= Socket.gethostname
|
|
end
|
|
|
|
def log_error(category, opts)
|
|
opts[:category] = category.to_s.presence || 'default'
|
|
return if ignored_categories.include? category
|
|
@opts = opts
|
|
# sanitize invalid encodings
|
|
@opts[:message] = Utf8Cleaner.strip_invalid_utf8(@opts[:message]) if @opts[:message]
|
|
if @opts[:exception_message]
|
|
@opts[:exception_message] = Utf8Cleaner.strip_invalid_utf8(@opts[:exception_message])
|
|
end
|
|
@opts[:hostname] = self.class.hostname
|
|
@opts[:pid] = Process.pid
|
|
run_callbacks :on_log_error
|
|
create_error_report(opts)
|
|
end
|
|
|
|
def log_exception(category, exception, opts)
|
|
category ||= exception.class.name
|
|
|
|
@exception = exception
|
|
message = exception.to_s rescue exception.class.name
|
|
backtrace = Array(exception.backtrace)
|
|
limit = 10
|
|
while (exception = exception.cause)
|
|
limit -= 1
|
|
break if limit == 0
|
|
cause = exception.to_s rescue exception.class.name
|
|
message += " caused by #{cause}"
|
|
new_backtrace = Array(exception.backtrace)
|
|
# remove the common lines of the backtrace, and separate it so you can see
|
|
# the error handling
|
|
backtrace = (new_backtrace - backtrace) + ["<Caused>"] + backtrace
|
|
end
|
|
opts[:message] ||= message
|
|
opts[:backtrace] = backtrace.join("\n")
|
|
opts[:exception_message] = message
|
|
log_error(category, opts)
|
|
end
|
|
|
|
def create_error_report(opts)
|
|
GuardRail.activate(:primary) do
|
|
begin
|
|
report = ErrorReport.new
|
|
report.assign_data(opts)
|
|
report.save!
|
|
Rails.logger.info("Created ErrorReport ID #{report.global_id}")
|
|
rescue => e
|
|
Rails.logger.error("Failed creating ErrorReport: #{e.inspect}")
|
|
Rails.logger.error("Original error: #{opts[:message]}")
|
|
Rails.logger.error("Original exception: #{opts[:exception_message]}") if opts[:exception_message]
|
|
@exception.backtrace.each do |line|
|
|
Rails.logger.error("Trace: #{line}")
|
|
end if @exception
|
|
end
|
|
report
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.configure_to_ignore(error_classes)
|
|
@classes_to_ignore ||= []
|
|
@classes_to_ignore += error_classes
|
|
end
|
|
|
|
def self.configured_to_ignore?(class_name)
|
|
(@classes_to_ignore || []).include?(class_name)
|
|
end
|
|
|
|
# returns the new error report
|
|
def self.log_error(category, opts = {})
|
|
Reporter.new.log_error(category, opts)
|
|
end
|
|
|
|
# returns the new error report
|
|
def self.log_exception(category, exception, opts = {})
|
|
Reporter.new.log_exception(category, exception, opts)
|
|
end
|
|
|
|
def self.log_captured(type, exception, error_report_info)
|
|
if exception.is_a?(String) || exception.is_a?(Symbol)
|
|
log_error(exception, error_report_info)
|
|
else
|
|
type = exception.class.name if type == :default
|
|
log_exception(type, exception, error_report_info)
|
|
end
|
|
end
|
|
|
|
def self.log_exception_from_canvas_errors(exception, data)
|
|
return nil if configured_to_ignore?(exception.class.to_s)
|
|
tags = data.fetch(:tags, {})
|
|
extras = data.fetch(:extra, {})
|
|
account_id = tags[:account_id]
|
|
domain_root_account = account_id ? Account.where(id: account_id).first : nil
|
|
error_report_info = tags.merge(extras)
|
|
type = tags.fetch(:type, :default)
|
|
|
|
if domain_root_account
|
|
domain_root_account.shard.activate do
|
|
ErrorReport.log_captured(type, exception, error_report_info)
|
|
end
|
|
else
|
|
(exception.try(:current_shard) || Shard.current).activate do
|
|
ErrorReport.log_captured(type, exception, error_report_info)
|
|
end
|
|
end
|
|
end
|
|
|
|
PROTECTED_FIELDS = [:id, :created_at, :updated_at, :data].freeze
|
|
|
|
# assigns data attributes to the column if there's a column with that name,
|
|
# otherwise goes into the general data hash
|
|
def assign_data(data = {})
|
|
self.data ||= {}
|
|
data.each do |k,v|
|
|
if respond_to?(:"#{k}=") && !ErrorReport::PROTECTED_FIELDS.include?(k.to_sym)
|
|
self.send(:"#{k}=", v)
|
|
else
|
|
# dup'ing because some strings come in from Rack as frozen sometimes,
|
|
# depending on the web server, and our invalid utf-8 stripping breaks on that
|
|
self.data[k.to_s] = v.is_a?(String) ? v.dup : v
|
|
end
|
|
end
|
|
end
|
|
|
|
def backtrace=(val)
|
|
if !val || val.length < self.class.maximum_text_length
|
|
write_attribute(:backtrace, val)
|
|
else
|
|
write_attribute(:backtrace, val[0,self.class.maximum_text_length])
|
|
end
|
|
end
|
|
|
|
def comments=(val)
|
|
if !val || val.length < self.class.maximum_text_length
|
|
write_attribute(:comments, val)
|
|
else
|
|
write_attribute(:comments, val[0,self.class.maximum_text_length])
|
|
end
|
|
end
|
|
|
|
def url=(val)
|
|
write_attribute(:url, LoggingFilter.filter_uri(val))
|
|
end
|
|
|
|
def safe_url?
|
|
uri = URI.parse(url)
|
|
['http', 'https'].include?(uri.scheme)
|
|
rescue
|
|
false
|
|
end
|
|
|
|
def guess_email
|
|
self.email = nil if self.email && self.email.empty?
|
|
self.email ||= self.user.email rescue nil
|
|
unless self.email
|
|
domain = HostUrl.outgoing_email_domain.gsub(/[^a-zA-Z0-9]/, '-')
|
|
# example.com definitely won't exist
|
|
self.email = "unknown-#{domain}@instructure.example.com"
|
|
end
|
|
self.email
|
|
end
|
|
|
|
# delete old error reports before a given date
|
|
# returns the number of destroyed error reports
|
|
def self.destroy_error_reports(before_date)
|
|
self.where("created_at<?", before_date).delete_all
|
|
end
|
|
|
|
def self.categories
|
|
distinct_values('category')
|
|
end
|
|
end
|