recolectar estadísticas usando menos recursos

This commit is contained in:
f 2021-10-26 11:33:15 -03:00
parent ab004fae70
commit c84462c4a8
5 changed files with 198 additions and 58 deletions

55
app/jobs/periodic_job.rb Normal file
View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
# Una tarea que se corre periódicamente
class PeriodicJob < ApplicationJob
class RunAgainException < StandardError; end
STARTING_INTERVAL = Stat::INTERVALS.first
# Tener el sitio a mano
attr_reader :site
# Descartar y notificar si pasó algo más.
#
# XXX: En realidad deberíamos seguir reintentando?
discard_on(StandardError) do |_, error|
ExceptionNotifier.notify_exception(error)
end
# Correr indefinidamente una vez por hora.
#
# XXX: El orden importa, si el descarte viene después, nunca se va a
# reintentar.
retry_on(PeriodicJob::RunAgainException, wait: 1.try(STARTING_INTERVAL), attempts: Float::INFINITY, jitter: 0)
private
# Las clases que implementen esta tienen que usar este método al
# terminar.
def run_again!
raise PeriodicJob::RunAgainException, 'Reintentando'
end
# El intervalo de inicio
#
# @return [Symbol]
def starting_interval
STARTING_INTERVAL
end
# La última recolección de estadísticas o empezar desde el principio
# de los tiempos.
#
# @return [Stat]
def last_stat
@last_stat ||= site.stats.where(name: stat_name).last ||
site.stats.build(created_at: Time.new(1970, 1, 1))
end
# Devuelve el comienzo del intervalo
#
# @return [Time]
def beginning_of_interval
@beginning_of_interval ||= last_stat.created_at.try(:"beginning_of_#{starting_interval}")
end
end

View file

@ -3,74 +3,65 @@
# Genera resúmenes de información para poder mostrar estadísticas y se
# corre regularmente a sí misma.
class StatCollectionJob < ApplicationJob
class CrontabException < StandardError; end
STAT_NAME = 'stat_collection_job'
# Descartar y notificar si pasó algo más.
#
# XXX: En realidad deberíamos seguir reintentando?
discard_on(Exception) do |_, error|
ExceptionNotifier.notify_exception error
def perform(site_id:, once: true)
@site = Site.find site_id
scope.rollup('builds', **options)
scope.rollup('space_used', **options) do |rollup|
rollup.average(:bytes)
end
# Correr indefinidamente una vez por hora.
#
# XXX: El orden importa, si el descarte viene después, nunca se va a
# reintentar.
retry_on(StatCollectionJob::CrontabException, wait: 1.hour, attempts: Float::INFINITY, jitter: 0)
COLUMNS = %i[uri].freeze
def perform(once: false)
Site.find_each do |site|
hostnames = [site.hostname, site.alternative_hostnames].flatten
# Usamos el primero porque luego podemos hacer un rollup recursivo
options = { interval: Stat::INTERVALS.first }
# Visitas por hostname
hostnames.each do |hostname|
AccessLog.where(host: hostname).completed_requests.non_robots.pages.group(:host).rollup('host', **options)
combined_columns(hostname, **options)
scope.rollup('build_time', **options) do |rollup|
rollup.average(:seconds)
end
stats_by_site(site, **options)
# XXX: Es correcto promediar promedios?
Stat::INTERVALS.reduce do |previous, current|
rollup(name: 'builds', interval_previous: previous, interval: current)
rollup(name: 'space_used', interval_previous: previous, interval: current, operation: :average)
rollup(name: 'build_time', interval_previous: previous, interval: current, operation: :average)
current
end
# Registrar que se hicieron todas las recolecciones
Stat.create!
site.stats.create! name: STAT_NAME
raise CrontabException unless once
run_again! unless once
end
private
# Combinación de columnas
def combined_columns(hostname, **options)
where = { host: hostname }
COLUMNS.each do |column|
AccessLog.where(host: hostname).pluck(Arel.sql("distinct #{column}")).each do |value|
where[column] = value
AccessLog.where(**where).completed_requests.non_robots.group(:host, column).rollup("host|#{column}", **options)
end
end
end
# Uso de recursos por cada sitio.
# Genera un rollup recursivo en base al período anterior y aplica una
# operación.
#
# XXX: En realidad se agrupan por el deploy_id, que siempre será el
# del DeployLocal.
def stats_by_site(site, **options)
site.build_stats.jekyll.group(:deploy_id).rollup('builds', **options)
site.build_stats.jekyll.group(:deploy_id).rollup('space_used', **options) do |rollup|
rollup.average(:bytes)
# @return [NilClass]
def rollup(name:, interval_previous:, interval:, operation: :sum)
Rollup.where(name: name, interval: interval_previous)
.where_dimensions(site_id: site.id)
.group("dimensions->'site_id'")
.rollup(name, interval: interval, update: true) do |rollup|
rollup.try(:operation, :value)
end
end
site.build_stats.jekyll.group(:deploy_id).rollup('build_time', **options) do |rollup|
rollup.average(:seconds)
# Los registros a procesar
#
# @return [ActiveRecord::Relation]
def scope
@scope ||= site.build_stats
.jekyll
.where('created_at => ?', beginning_of_interval)
.group(:site_id)
end
# Las opciones por defecto
#
# @return [Hash]
def options
@options ||= { interval: starting_interval, update: true }
end
end

View file

@ -1,16 +1,105 @@
# frozen_string_literal: true
# Procesar una lista de URIs para una lista de dominios. Esto nos
# permite procesar estadísticas a demanada.
class UriCollectionJob < ApplicationJob
def perform(hostnames:, file:)
uris = File.read(file).split("\n")
# permite procesar estadísticas a demanda.
#
# Hay varias cosas acá que van a convertirse en métodos propios, como la
# detección de URIs de un sitio (aunque la versión actual detecta todas
# las páginas y no solo las de posts como tenemos planeado, hay que
# resolver eso).
#
# Los hostnames de un sitio van a poder obtenerse a partir de
# Site#hostnames con la garantía de que son únicos.
class UriCollectionJob < PeriodicJob
# Ignoramos imágenes porque suelen ser demasiadas y no aportan a las
# estadísticas.
IMAGES = %w[.png .jpg .jpeg .gif .webp].freeze
STAT_NAME = 'uri_collection_job'
def perform(site_id:, once: true)
@site = Site.find site_id
hostnames.each do |hostname|
uris.each do |uri|
break if File.exist? Rails.root.join('tmp', 'uri_collection_job_stop')
return if File.exist? Rails.root.join('tmp', 'uri_collection_job_stop')
AccessLog.where(host: hostname, uri: uri).completed_requests.non_robots.group(:host, :uri).rollup('host|uri', interval: 'day')
AccessLog.where(host: hostname, uri: uri)
.where('created_at >= ?', beginning_of_interval)
.completed_requests
.non_robots
.group(:host, :uri)
.rollup('host|uri', interval: starting_interval, update: true)
# Reducir las estadísticas calculadas aplicando un rollup sobre el
# intervalo más amplio.
Stat::INTERVALS.reduce do |previous, current|
Rollup.where(name: 'host|uri', interval: previous)
.where_dimensions(host: hostname, uri: uri)
.group("dimensions->'host'", "dimensions->'uri'")
.rollup('host|uri', interval: current, update: true) do |rollup|
rollup.sum(:value)
end
# Devolver el intervalo actual
current
end
end
end
# Recordar la última vez que se corrió la tarea
site.stats.create! name: STAT_NAME
run_again! unless once
end
private
def stat_name
STAT_NAME
end
# @return [String]
#
# TODO: Cambiar al mergear origin-referer
def destination
@destination ||= site.deploys.find_by(type: 'DeployLocal').destination
end
# TODO: Cambiar al mergear origin-referer
#
# @return [Array]
def hostnames
@hostnames ||= site.deploys.map do |deploy|
case deploy
when DeployLocal
site.hostname
when DeployWww
deploy.fqdn
when DeployAlternativeDomain
deploy.hostname.dup.tap do |h|
h.replace(h.end_with?('.') ? h[0..-2] : "#{h}.#{Site.domain}")
end
when DeployHiddenService
deploy.onion
end
end.compact
end
# Recolecta todas las URIs menos imágenes
#
# @return [Array]
def uris
@uris ||= Dir.chdir destination do
(Dir.glob('**/*.html') + Dir.glob('public/**/*').reject do |p|
File.directory? p
end.reject do |p|
p = p.downcase
IMAGES.any? do |i|
p.end_with? i
end
end).map do |uri|
"/#{uri}"
end
end
end

View file

@ -37,6 +37,7 @@ class Site < ApplicationRecord
belongs_to :design
belongs_to :licencia
has_many :stats
has_many :log_entries, dependent: :destroy
has_many :deploys, dependent: :destroy
has_many :build_stats, through: :deploys

View file

@ -1,6 +1,10 @@
# frozen_string_literal: true
# Registran cuándo fue la última recolección de datos.
class Stat < ApplicationRecord
INTERVALS = %i[year month day].freeze
# XXX: Los intervalos van en orden de mayor especificidad a menor
INTERVALS = %i[day month year].freeze
RESOURCES = %i[builds space_used build_time].freeze
belongs_to :site
end