# frozen_string_literal: true # adn/tools/logger.rb — Logger estructurado JSON para el sistema ADN # ==================================================================== # Este módulo proporciona logging estructurado en JSON para todas las # herramientas del sistema ADN. Los logs se almacenan en `logs/adn/` # con rotación diaria automática y por tamaño. require 'json' require 'fileutils' require 'date' require 'time' module ADN class Logger NIVELES = %w[DEBUG INFO EXITO ADVERTENCIA ERROR].freeze # Configuración de rotación MAX_TAMANO_MB = 10 MAX_ARCHIVOS = 7 require_relative 'colores' def initialize(opts = {}) @directorio = opts[:directorio] || File.join(File.expand_path('../../..', __dir__), 'logs', 'adn') @mostrar_terminal = opts.fetch(:terminal, true) @usar_colores = opts.fetch(:colores, true) @archivo_base = opts[:archivo] || Date.today.iso8601 # Nivel mínimo de log (configurable) @nivel_minimo = opts[:nivel] || 'DEBUG' FileUtils.mkdir_p(@directorio) unless Dir.exist?(@directorio) @ruta_log = File.join(@directorio, "#{@archivo_base}.log") @buffer = [] # Verificar rotación al iniciar verificar_rotacion end # Registra un mensaje de nivel DEBUG # # @param mensaje [String] Mensaje descriptivo # @param datos [Hash] Datos adicionales estructurados # @param opts [Hash] Opciones adicionales # @option opts [Boolean] :silencioso No mostrar en terminal def debug(mensaje, datos = {}, opts = {}) escribir('DEBUG', mensaje, datos, opts) mostrar_terminal('🐛', mensaje, Color::DIM, opts) if debug_activado? end # Registra un mensaje de nivel INFO # # @param mensaje [String] Mensaje descriptivo # @param datos [Hash] Datos adicionales estructurados # @param opts [Hash] Opciones adicionales # @option opts [Boolean] :silencioso No mostrar en terminal def info(mensaje, datos = {}, opts = {}) escribir('INFO', mensaje, datos, opts) mostrar_terminal('ℹ', mensaje, Color::CYAN, opts) end # Registra un mensaje de nivel ÉXITO # # @param mensaje [String] Mensaje descriptivo # @param datos [Hash] Datos adicionales estructurados # @param opts [Hash] Opciones adicionales # @option opts [Boolean] :silencioso No mostrar en terminal def exito(mensaje, datos = {}, opts = {}) escribir('EXITO', mensaje, datos, opts) mostrar_terminal('✓', mensaje, Color::GREEN, opts) end # Registra un mensaje de nivel ADVERTENCIA # # @param mensaje [String] Mensaje descriptivo # @param datos [Hash] Datos adicionales estructurados # @param opts [Hash] Opciones adicionales # @option opts [Boolean] :silencioso No mostrar en terminal def advertencia(mensaje, datos = {}, opts = {}) escribir('ADVERTENCIA', mensaje, datos, opts) mostrar_terminal('⚠', mensaje, Color::YELLOW, opts) end # Alias inglés para advertencia # # @param mensaje [String] Mensaje descriptivo # @param datos [Hash] Datos adicionales estructurados # @param opts [Hash] Opciones adicionales # @option opts [Boolean] :silencioso No mostrar en terminal def warn(mensaje, datos = {}, opts = {}) advertencia(mensaje, datos, opts) end # Registra un mensaje de nivel ERROR # # @param mensaje [String] Mensaje descriptivo # @param datos [Hash] Datos adicionales estructurados # @param opts [Hash] Opciones adicionales # @option opts [Boolean] :silencioso No mostrar en terminal def error(mensaje, datos = {}, opts = {}) escribir('ERROR', mensaje, datos, opts) mostrar_terminal('✗', mensaje, Color::RED, opts) end # Muestra una sugerencia de comandos ADN a utilizar # # @param comandos [String, Array] Comando o comandos sugeridos def sugerencia(comandos) comandos = [comandos] unless comandos.is_a?(Array) puts "" puts " 💡 #{Color::BOLD}Sugerencia:#{Color::RESET} Podés solucionar esto usando las herramientas ADN:" comandos.each do |cmd| puts " #{Color::CYAN}#{cmd}#{Color::RESET}" end puts "" end # Obtiene los logs almacenados en buffer # # @return [Array] Logs en buffer def buffer @buffer.dup end # Limpia el buffer interno def limpiar_buffer @buffer.clear end # Cierra cualquier recurso del logger (archivos abiertos, etc.) def cerrar # Por ahora no hay recursos persistentes abiertos true end # Verifica si el debug está activado (variable de entorno ADN_DEBUG) # # @return [Boolean] def debug_activado? ENV['ADN_DEBUG'] == '1' || ENV['ADN_DEBUG'] == 'true' end # Ruta actual del archivo de log # # @return [String] def ruta_log @ruta_log end private # Escribe una entrada de log en formato JSON # # @param nivel [String] Nivel del log # @param mensaje [String] Mensaje descriptivo # @param datos [Hash] Datos adicionales estructurados # @param opts [Hash] Opciones adicionales def escribir(nivel, mensaje, datos, opts) entrada = { timestamp: Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S%z'), nivel: nivel, mensaje: mensaje, datos: datos.reject { |k, _| k == :silencioso }, # Excluir opciones internas herramienta: opts[:herramienta] || determinar_herramienta_llamante } # Agregar al buffer @buffer << entrada.dup # Escribir al archivo File.open(@ruta_log, 'a') do |archivo| archivo.puts(entrada.to_json) end end # Muestra el mensaje en terminal (si está habilitado) # # @param icono [String] Icono para representar el nivel # @param mensaje [String] Mensaje a mostrar # @param color [String] Código ANSI de color # @param opts [Hash] Opciones adicionales def mostrar_terminal(icono, mensaje, color, opts) return unless @mostrar_terminal && !opts[:silencioso] if @usar_colores puts "#{color}#{icono} #{mensaje}#{Color::RESET}" else puts "#{icono} #{mensaje}" end end # Intenta determinar qué herramienta llamó al logger # # @return [String] Nombre de la herramienta def determinar_herramienta_llamante # Obtener el backtrace y buscar llamadas desde adn/tools/ caller_locations.each do |loc| ruta = loc.absolute_path || loc.path if ruta.include?('adn/tools') # Extraer nombre del archivo sin extensión nombre = File.basename(ruta, '.*') return "adn.#{nombre}" unless nombre.empty? end end 'adn.desconocido' end # Verifica y ejecuta rotación de logs si es necesario def verificar_rotacion return unless File.exist?(@ruta_log) tamano_mb = File.size(@ruta_log) / (1024.0 * 1024.0) if tamano_mb > MAX_TAMANO_MB rotar_por_tamano end end # Rota el log por tamaño (archivo muy grande) def rotar_por_tamano timestamp = Time.now.strftime('%Y%m%d_%H%M%S') archivo_rotado = "#{@ruta_log}.#{timestamp}" FileUtils.mv(@ruta_log, archivo_rotado) limpiar_logs_antiguos puts "#{Color::YELLOW}⚠ Log rotado por tamaño: #{archivo_rotado}#{Color::RESET}" end # Limpia logs antiguos (más de MAX_ARCHIVOS) def limpiar_logs_antiguos patron = File.join(@directorio, '*.log.*') archivos = Dir.glob(patron).sort_by { |f| File.mtime(f) } archivos[0...-MAX_ARCHIVOS].each do |archivo| File.delete(archivo) if File.exist?(archivo) end end # Verifica si el nivel está habilitado def nivel_habilitado?(nivel) idx_actual = NIVELES.index(@nivel_minimo) || 0 idx_nivel = NIVELES.index(nivel) || 0 idx_nivel >= idx_actual end end # Logger global preconfigurado para uso rápido # # @return [ADN::Logger] def self.logger @logger ||= Logger.new end # Configura el logger global # # @param opts [Hash] Opciones de configuración # @return [ADN::Logger] def self.configurar_logger(opts = {}) @logger = Logger.new(opts) end end