canvas-lms/app/models/error_report.rb

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