# frozen_string_literal: true # Notifica excepciones a una instancia de Gitlab, como incidencias # nuevas o como comentarios a las incidencias pre-existentes. class GitlabNotifierJob < ApplicationJob class GitlabNotifierError < StandardError; end include ExceptionNotifier::BacktraceCleaner # Variables que vamos a acceder luego attr_reader :exception, :options, :issue_data, :cached # @param [Exception] la excepción lanzada # @param [Hash] opciones de ExceptionNotifier def perform(exception, **options) @exception = exception @options = fix_options options @issue_data = { count: 1 } # Necesitamos saber si el issue ya existía @cached = false @issue = {} # Traemos los datos desde la caché si existen, sino generamos un # issue nuevo e inicializamos la caché @issue_data = Rails.cache.fetch(cache_key) do @issue = client.new_issue confidential: true, title: title, description: description, issue_type: 'incident' @cached = true { count: 1, issue: @issue['iid'], user_agents: [user_agent].compact, params: request&.filtered_parameters&.as_json, urls: [url].compact } end if @issue['iid'].blank? && issue_data[:issue].blank? Rails.cache.delete(cache_key) raise GitlabNotifierError, @issue.dig('message', 'title')&.join(', ') end # No seguimos actualizando si acabamos de generar el issue return if cached # Incrementar la cuenta de veces que ocurrió issue_data[:count] += 1 # Guardar información útil issue_data[:urls] << url unless issue_data[:urls].include? url issue_data[:user_agents] << user_agent unless issue_data[:user_agents].include? user_agent # Editar el título para que incluya la cuenta de eventos client.edit_issue(iid: issue_data[:issue], title: title, state_event: 'reopen') # Agregar un comentario con la información posiblemente nueva client.new_note(iid: issue_data[:issue], body: body) # Guardar para después Rails.cache.write(cache_key, issue_data) # Si este trabajo genera una excepción va a entrar en un loop, así que # la notificamos por correo rescue StandardError => e email_notification.call(e, data: @issue) email_notification.call(exception, data: @options) end private # Notificar por correo # # @return [ExceptionNotifier::EmailNotifier] def email_notification @email_notification ||= ExceptionNotifier::EmailNotifier.new(email_prefix: '[ERROR] ', sender_address: ENV['DEFAULT_FROM'], exception_recipients: ENV['EXCEPTION_TO']) end # La llave en la cache tiene en cuenta la excepción, el mensaje, la # ruta del backtrace y los errores de JS # # @return [String] def cache_key @cache_key ||= [ exception.class.name, Digest::SHA1.hexdigest(exception.message), Digest::SHA1.hexdigest(backtrace&.first.to_s), Digest::SHA1.hexdigest(errors.to_s) ].join('/') end # @return [Array] def errors options.dig(:data, :params, 'errors') || [] end # Define si es una excepción de javascript o local # # @see BacktraceJob # @return [Boolean] def javascript? @javascript ||= options.dig(:data, :javascript_backtrace).present? end # Título # # @return [String] def title @title ||= ''.dup.tap do |t| t << "[#{exception.class}] " unless javascript? t << exception.message[0..200] t << " [#{issue_data[:count]}]" end end # Descripción # # @return [String] def description @description ||= ''.dup.tap do |d| d << log_section d << request_section d << javascript_section d << javascript_footer d << backtrace_section d << data_section end end # Comentario # # @return [String] def body @body ||= ''.dup.tap do |b| b << log_section b << request_section b << javascript_footer b << data_section end end # Cadena de archivos donde se produjo el error # # @return [Array,Nil] def backtrace @backtrace ||= exception.backtrace ? clean_backtrace(exception) : nil end # Entorno del error # # @return [Hash] def env options[:env] end # Genera una petición a partir del entorno # # @return [ActionDispatch::Request] def request @request ||= ActionDispatch::Request.new(env) if env.present? end # Cliente de la API de Gitlab # # @return [GitlabApiClient] def client @client ||= GitlabApiClient.new end # @return [String] def log_section return '' unless options.dig(:data, :log) <<~LOG # Build log ``` #{options[:data].delete(:log)} ``` LOG end # Muestra información de la petición # # @return [String] def request_section return '' unless request <<~REQUEST # Request ``` #{request.request_method} #{url} #{pp request.filtered_parameters.as_json} ``` REQUEST end # Muestra información de JavaScript # # @return [String] def javascript_section return '' unless javascript? options.dig(:data, :params, 'errors')&.map do |error| # Algunos errores no son excepciones (?) error['type'] = 'undefined' if error['type'].blank? <<~JAVASCRIPT ## #{error['type']}: #{error['message']} ``` #{Terminal::Table.new headings: error['backtrace'].first.keys, rows: error['backtrace'].map(&:values)} ``` JAVASCRIPT end&.join end # Muestra información de la visita que generó el error en JS # # @return [String] def javascript_footer return '' unless javascript? <<~JAVASCRIPT #{user_agent} <#{url}> JAVASCRIPT end # Muestra el historial del error en Ruby # # @return [String] def backtrace_section return '' if javascript? return '' unless backtrace <<~BACKTRACE ## Backtrace ``` #{backtrace.join("\n")} ``` BACKTRACE end # Muestra datos extra de la visita # # @return [String] def data_section return '' unless options[:data] <<~DATA ## Data ```yaml #{options[:data].to_yaml} ``` DATA end # Obtiene el UA de este error # # @return [String] def user_agent @user_agent ||= options.dig(:data, :params, 'context', 'userAgent') if javascript? @user_agent ||= request.headers['user-agent'] if request @user_agent end # Obtiene la URL actual # # @return [String] def url @url ||= request&.url || options.dig(:data, :params, 'context', 'url') end # Define llaves necesarias # # @param :options [Hash] # @return [Hash] def fix_options(options) options = { data: options } unless options.is_a? Hash options[:data] ||= {} options[:data][:params] ||= {} options end end