reportar el issue una sola vez y luego actualizarlo

This commit is contained in:
f 2021-05-31 12:08:19 -03:00
parent fc7c2f31dd
commit 5468a11885
2 changed files with 130 additions and 22 deletions

View file

@ -1,42 +1,98 @@
# frozen_string_literal: true # 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 GitlabNotifierJob < ApplicationJob
include ExceptionNotifier::BacktraceCleaner include ExceptionNotifier::BacktraceCleaner
attr_reader :exception, :options # Variables que vamos a acceder luego
attr_reader :exception, :options, :issue_data, :cached
queue_as :low_priority queue_as :low_priority
# @param [Exception] la excepción lanzada
# @param [Hash] opciones de ExceptionNotifier
def perform(exception, **options) def perform(exception, **options)
@exception = exception @exception = exception
@options = options @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 rescue Exception => e
Rails.logger.info 'No entrar en loop' Rails.logger.info 'No entrar en loop'
end end
private 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 # Define si es una excepción de javascript o local
# #
# @see BacktraceJob # @see BacktraceJob
# @return [Boolean]
def javascript? def javascript?
@javascript ||= options.dig(:data, :javascript_backtrace).present? @javascript ||= options.dig(:data, :javascript_backtrace).present?
end end
# Título
#
# @return [String] # @return [String]
def title def title
@title ||= ''.dup.tap do |t| @title ||= ''.dup.tap do |t|
t << "[#{exception.class}] " unless javascript? t << "[#{exception.class}] " unless javascript?
t << exception.message t << exception.message
t << " [#{issue_data[:count]}]"
end end
end end
# Descripción
#
# @return [String] # @return [String]
def description def description
@description ||= ''.dup.tap do |d| @description ||= ''.dup.tap do |d|
@ -48,24 +104,48 @@ class GitlabNotifierJob < ApplicationJob
end end
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 def backtrace
@backtrace ||= exception.backtrace ? clean_backtrace(exception) : nil @backtrace ||= exception.backtrace ? clean_backtrace(exception) : nil
end end
# Entorno del error
#
# @return [Hash]
def env def env
options[:env] options[:env]
end end
# Genera una petición a partir del entorno
#
# @return [ActionDispatch::Request]
def request def request
@request ||= ActionDispatch::Request.new(env) if env.present? @request ||= ActionDispatch::Request.new(env) if env.present?
end end
# Cliente de la API de Gitlab
#
# @return [GitlabApiClient] # @return [GitlabApiClient]
def client def client
@client ||= GitlabApiClient.new @client ||= GitlabApiClient.new
end end
# Muestra información de la petición
#
# @return [String]
def request_section def request_section
return '' unless request return '' unless request
@ -74,7 +154,7 @@ class GitlabNotifierJob < ApplicationJob
# Request # Request
``` ```
#{request.request_method} #{request.url}#{' '} #{request.request_method} #{url}
#{pp request.filtered_parameters} #{pp request.filtered_parameters}
``` ```
@ -82,14 +162,19 @@ class GitlabNotifierJob < ApplicationJob
REQUEST REQUEST
end end
# Muestra información de JavaScript
#
# @return [String]
def javascript_section def javascript_section
return '' unless javascript? return '' unless javascript?
options.dig(:data, :params, 'errors')&.map do |error| options.dig(:data, :params, 'errors')&.map do |error|
# Algunos errores no son excepciones (?)
error['type'] = 'undefined' if error['type'].blank?
<<~JAVASCRIPT <<~JAVASCRIPT
## #{error['type'] || 'NoError'}: #{error['message']} ## #{error['type']}: #{error['message']}
``` ```
#{Terminal::Table.new headings: error['backtrace'].first.keys, rows: error['backtrace'].map(&:values)} #{Terminal::Table.new headings: error['backtrace'].first.keys, rows: error['backtrace'].map(&:values)}
@ -99,18 +184,24 @@ class GitlabNotifierJob < ApplicationJob
end&.join end&.join
end end
# Muestra información de la visita que generó el error en JS
#
# @return [String]
def javascript_footer def javascript_footer
return '' unless javascript? return '' unless javascript?
<<~JAVASCRIPT <<~JAVASCRIPT
#{options.dig(:data, :params, 'context', 'userAgent')} #{user_agent}
<#{options.dig(:data, :params, 'context', 'url')}> <#{url}>
JAVASCRIPT JAVASCRIPT
end end
# Muestra el historial del error en Ruby
#
# @return [String]
def backtrace_section def backtrace_section
return '' if javascript? return '' if javascript?
return '' unless backtrace return '' unless backtrace
@ -126,6 +217,9 @@ class GitlabNotifierJob < ApplicationJob
BACKTRACE BACKTRACE
end end
# Muestra datos extra de la visita
#
# @return [String]
def data_section def data_section
return '' unless options[:data] return '' unless options[:data]
@ -139,4 +233,20 @@ class GitlabNotifierJob < ApplicationJob
DATA DATA
end 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 end

View file

@ -7,6 +7,9 @@ class GitlabApiClient
# TODO: Hacer configurable por sitio # TODO: Hacer configurable por sitio
base_uri ENV.fetch('GITLAB_URI', 'https://0xacab.org') 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 no_follow true
# Trae todos los proyectos. Como estamos usando un Project Token, # Trae todos los proyectos. Como estamos usando un Project Token,
@ -14,7 +17,7 @@ class GitlabApiClient
# #
# @return [HTTParty::Response] # @return [HTTParty::Response]
def projects def projects
self.class.get('/api/v4/projects', { query: params(membership: true) }) self.class.get('/api/v4/projects', { query: { membership: true }, headers: headers })
end end
# Obtiene el identificador del proyecto # Obtiene el identificador del proyecto
@ -29,15 +32,15 @@ class GitlabApiClient
# @see https://docs.gitlab.com/ee/api/issues.html#new-issue # @see https://docs.gitlab.com/ee/api/issues.html#new-issue
# @return [HTTParty::Response] # @return [HTTParty::Response]
def new_issue(**args) 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 end
# Modifica un issue # Modifica un issue
# #
# @see https://docs.gitlab.com/ee/api/issues.html#edit-issue # @see https://docs.gitlab.com/ee/api/issues.html#edit-issue
# @return [HTTParty::Response] # @return [HTTParty::Response]
def edit_issue(**args) def edit_issue(iid:, **args)
self.class.put("/api/v4/projects/#{project_id}/issues", { query: params(**args) }) self.class.put("/api/v4/projects/#{project_id}/issues/#{iid}", { body: args, headers: headers })
end end
# Crea un comentario # Crea un comentario
@ -45,17 +48,12 @@ class GitlabApiClient
# @see https://docs.gitlab.com/ee/api/notes.html#create-new-issue-note # @see https://docs.gitlab.com/ee/api/notes.html#create-new-issue-note
# @return [HTTParty::Response] # @return [HTTParty::Response]
def new_note(iid:, **args) 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 end
private private
def params(**args) def headers(extra = {})
default_params.merge(args) { 'Authorization' => "Bearer #{ENV['GITLAB_TOKEN']}" }.merge(extra)
end
# TODO: Que cada sitio tenga su propio token y uri
def default_params
{ private_token: ENV['GITLAB_TOKEN'] }
end end
end end