From 5468a118851b92271e1d081ca386438970101d31 Mon Sep 17 00:00:00 2001 From: f Date: Mon, 31 May 2021 12:08:19 -0300 Subject: [PATCH] reportar el issue una sola vez y luego actualizarlo --- app/jobs/gitlab_notifier_job.rb | 130 +++++++++++++++++++++++++++++--- app/lib/gitlab_api_client.rb | 22 +++--- 2 files changed, 130 insertions(+), 22 deletions(-) diff --git a/app/jobs/gitlab_notifier_job.rb b/app/jobs/gitlab_notifier_job.rb index 4d676b8d..ac712ab2 100644 --- a/app/jobs/gitlab_notifier_job.rb +++ b/app/jobs/gitlab_notifier_job.rb @@ -1,42 +1,98 @@ # 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 include ExceptionNotifier::BacktraceCleaner - attr_reader :exception, :options + # Variables que vamos a acceder luego + attr_reader :exception, :options, :issue_data, :cached queue_as :low_priority + # @param [Exception] la excepción lanzada + # @param [Hash] opciones de ExceptionNotifier def perform(exception, **options) @exception = exception @options = options + @issue_data = { count: 1 } + # Necesitamos saber si el issue ya existía + @cached = false - Rails.logger.info 'Enviando reporte a Gitlab' + # 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 - i = client.new_issue confidential: true, title: title, description: description + { + count: 1, + issue: issue['iid'], + user_agents: [user_agent].compact, + params: [request&.filtered_parameters].compact, + urls: [url].compact + } + end - Rails.logger.info "Enviado reporte a Gitlab: #{i['iid']}" + # 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 + # TODO: Notificarnos por otros medios (mail) rescue Exception => e Rails.logger.info 'No entrar en loop' end private + # 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(options.dig(:data, :params, 'errors').to_s) + ].join('/') + 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 + t << " [#{issue_data[:count]}]" end end + # Descripción + # # @return [String] def description @description ||= ''.dup.tap do |d| @@ -48,24 +104,48 @@ class GitlabNotifierJob < ApplicationJob end end - # @return [String,Nil] + # Comentario + # + # @return [String] + def body + @body ||= ''.dup.tap do |b| + 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 + # Muestra información de la petición + # + # @return [String] def request_section return '' unless request @@ -74,7 +154,7 @@ class GitlabNotifierJob < ApplicationJob # Request ``` - #{request.request_method} #{request.url}#{' '} + #{request.request_method} #{url} #{pp request.filtered_parameters} ``` @@ -82,14 +162,19 @@ class GitlabNotifierJob < ApplicationJob 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'] || 'NoError'}: #{error['message']} - + ## #{error['type']}: #{error['message']} ``` #{Terminal::Table.new headings: error['backtrace'].first.keys, rows: error['backtrace'].map(&:values)} @@ -99,18 +184,24 @@ class GitlabNotifierJob < ApplicationJob end&.join end + # Muestra información de la visita que generó el error en JS + # + # @return [String] def javascript_footer return '' unless javascript? <<~JAVASCRIPT - #{options.dig(:data, :params, 'context', 'userAgent')} + #{user_agent} - <#{options.dig(:data, :params, 'context', 'url')}> + <#{url}> JAVASCRIPT end + # Muestra el historial del error en Ruby + # + # @return [String] def backtrace_section return '' if javascript? return '' unless backtrace @@ -126,6 +217,9 @@ class GitlabNotifierJob < ApplicationJob BACKTRACE end + # Muestra datos extra de la visita + # + # @return [String] def data_section return '' unless options[:data] @@ -139,4 +233,20 @@ class GitlabNotifierJob < ApplicationJob 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 end diff --git a/app/lib/gitlab_api_client.rb b/app/lib/gitlab_api_client.rb index c8157b47..5b1287d6 100644 --- a/app/lib/gitlab_api_client.rb +++ b/app/lib/gitlab_api_client.rb @@ -7,6 +7,9 @@ class GitlabApiClient # TODO: Hacer configurable por sitio base_uri ENV.fetch('GITLAB_URI', 'https://0xacab.org') + # No seguir redirecciones. Si nos olvidamos https:// en la dirección, + # las redirecciones nos pueden llevar a cualquier lado y obtener + # resultados diferentes. no_follow true # Trae todos los proyectos. Como estamos usando un Project Token, @@ -14,7 +17,7 @@ class GitlabApiClient # # @return [HTTParty::Response] def projects - self.class.get('/api/v4/projects', { query: params(membership: true) }) + self.class.get('/api/v4/projects', { query: { membership: true }, headers: headers }) end # Obtiene el identificador del proyecto @@ -29,15 +32,15 @@ class GitlabApiClient # @see https://docs.gitlab.com/ee/api/issues.html#new-issue # @return [HTTParty::Response] def new_issue(**args) - self.class.post("/api/v4/projects/#{project_id}/issues", { query: params(**args) }) + self.class.post("/api/v4/projects/#{project_id}/issues", { body: args, headers: headers }) end # Modifica un issue # # @see https://docs.gitlab.com/ee/api/issues.html#edit-issue # @return [HTTParty::Response] - def edit_issue(**args) - self.class.put("/api/v4/projects/#{project_id}/issues", { query: params(**args) }) + def edit_issue(iid:, **args) + self.class.put("/api/v4/projects/#{project_id}/issues/#{iid}", { body: args, headers: headers }) end # Crea un comentario @@ -45,17 +48,12 @@ class GitlabApiClient # @see https://docs.gitlab.com/ee/api/notes.html#create-new-issue-note # @return [HTTParty::Response] def new_note(iid:, **args) - self.class.post("/api/v4/projects/#{project_id}/issues/#{iid}/notes", { query: params(**args) }) + self.class.post("/api/v4/projects/#{project_id}/issues/#{iid}/notes", { body: args, headers: headers }) end private - def params(**args) - default_params.merge(args) - end - - # TODO: Que cada sitio tenga su propio token y uri - def default_params - { private_token: ENV['GITLAB_TOKEN'] } + def headers(extra = {}) + { 'Authorization' => "Bearer #{ENV['GITLAB_TOKEN']}" }.merge(extra) end end