lib/semantic_logger/appender/file.rb in semantic_logger-4.8.2 vs lib/semantic_logger/appender/file.rb in semantic_logger-4.9.0

- old
+ new

@@ -1,119 +1,291 @@ +require "date" # File appender # # Writes log messages to a file or open iostream # module SemanticLogger module Appender class File < SemanticLogger::Subscriber - # Create a File Logger appender instance. + attr_accessor :file_name, :retry_count, :append, :exclusive_lock, :encoding, + :reopen_period, :reopen_count, :reopen_size + attr_reader :log_count, :log_size, :current_file_name, :reopen_at + + # Create an appender to log to a named file. # # Parameters - # :file_name [String] - # Name of file to write to. - # Or, - # :io [IO] - # An IO stream to which to write the log messages to. + # file_name [String] + # Name of the file to write to. # - # :level [:trace | :debug | :info | :warn | :error | :fatal] - # Override the log level for this appender. - # Default: SemanticLogger.default_level + # File name format directives: + # %p - Process Id + # %n - Short hostname (SemanticLogger.host). Everything before the first period in the hostname. + # %N - Full hostname (SemanticLogger.host) + # %a - Application name (SemanticLogger.application) + # %e - Environment name (SemanticLogger.environment) + # %D - Current Date. Equivalent to "%Y%m%d" + # %T - Current Time. Equivalent to "%H%M%S" + # %% - Literal `%` character # - # :formatter: [Object|Proc] - # An instance of a class that implements #call, or a Proc to be used to format - # the output from this appender - # Default: Use the built-in formatter (See: #call) + # Date: + # %Y - Year with century + # %C - year / 100 (round down. 20 in 2009) + # %y - year % 100 (00..99) + # %m - Month of the year, zero-padded (01..12) + # %d - Day of the month, zero-padded (01..31) + # %j - Day of the year (001..366) + # %U - Week number of the year. The week starts with Sunday. (00..53) + # %W - Week number of the year. The week starts with Monday. (00..53) # - # :filter [Regexp|Proc] - # RegExp: Only include log messages where the class name matches the supplied - # regular expression. All other messages will be ignored. - # Proc: Only include log messages where the supplied Proc returns true - # The Proc must return true or false. + # Time: + # %H - 24 Hour of the day, zero-padded (00..23) + # %M - Minute of the hour (00..59) + # %S - Second of the minute (00..60) # - # Example - # require 'semantic_logger' + # Examples: + # Create a log file name consisting of the short host name, process id, date, and time. + # "log/production-%n-%p-%D-%T.log" # - # # Enable trace level logging - # SemanticLogger.default_level = :info + # :level [:trace | :debug | :info | :warn | :error | :fatal] + # Override the log level for this appender. + # Default: SemanticLogger.default_level # - # # Log to screen - # SemanticLogger.add_appender(io: $stdout, formatter: :color) + # :formatter: [Object|Proc] + # An instance of a class that implements #call, or a Proc to be used to format + # the output from this appender + # Default: Use the built-in formatter (See: #call) # - # # And log to a file at the same time - # SemanticLogger.add_appender(file_name: 'application.log', formatter: :color) + # :filter [Regexp|Proc] + # RegExp: Only include log messages where the class name matches the supplied + # regular expression. All other messages will be ignored. + # Proc: Only include log messages where the supplied Proc returns true + # The Proc must return true or false. # - # logger = SemanticLogger['test'] - # logger.info 'Hello World' + # :append [true|false] + # Append to the log file if already present? + # Default: true # - # Example 2. To log all levels to file and only :info and above to screen: + # :exclusive_lock [true|false] + # Obtain an exclusive lock on the file, for operating systems that support it. + # Prevents multiple processes from trying to write to the same log file. + # Default: false # - # require 'semantic_logger' + # :encoding ["UTF-8", "UTF-16", etc.] + # Encoding to use when writing to the file. + # Default: Encoding::BINARY # - # # Enable trace level logging - # SemanticLogger.default_level = :trace + # :retry_count [Integer] + # Number of times to attempt to re-open the file name when an error occurs trying to + # write to the file. + # Note: Set to 0 to disable retries. + # Default: 1 # - # # Log to screen but only display :info and above - # SemanticLogger.add_appender(io: $stdout, level: :info) + # :reopen_period [String] + # Specify a period after which to re-open the log file, specified in minutes, hours, or days. + # The format of the duration must start with an Integer or Float number, + # followed by the duration specified as: + # "m" : minutes + # "h" : hours + # "d" : days + # The time is rounded down to the specified time interval, so that: + # - "1h" will re-open every hour at the beginning of the hour. + # - "30m" will re-open every 30 minutes at the beginning of the 30th minute. + # - "1d" will re-open every day at midnight. + # Examples: + # "60m" : Every 60 minutes at the beginning of the minute: 10:24:00, 11:24:00, 12:24:00, ... + # "1h" : Every hour at the beginning of the hour: 10:00:00, 11:00:00, 12:00:00, ... + # "1d" : Every day at the beginning of the day: "20211008 00:00:00", "20211009 00:00:00", ... + # Default: nil (Disabled) # - # # And log to a file at the same time, including all :trace level data - # SemanticLogger.add_appender(file_name: 'application.log') + # :reopen_count [Integer] + # Close and re-open the log file after every `reopen_count` number of logged entries. + # Default: 0 (Disabled) # - # logger = SemanticLogger['test'] - # logger.info 'Hello World' - def initialize(io: nil, file_name: nil, **args, &block) - if io - @log = io - unless @log.respond_to?(:write) - raise(ArgumentError, "SemanticLogging::Appender::File :io is not a valid IO instance: #{io.inspect}") - end - else - @file_name = file_name - unless file_name - raise(ArgumentError, "SemanticLogging::Appender::File missing mandatory parameter :file_name or :io") - end - - reopen + # :reopen_size [Integer] + # Approximate number of bytes to write to a log file by this process before closing and re-opening it. + # Notes: + # - When `append: true` and the file already exists, it reads the size of the current log file + # and starts with that size. + # - If the current log file size already exceeds the `reopen_size`, its current size is ignored. + # - The `reopen_size` is only the amount of bytes written by this process, it excludes data + # written by other processes. Use a unique filename to prevent multiple processes from writing to + # the same log file at the same time. + # Default: 0 (Disabled) + # + # Example + # require "semantic_logger" + # + # # Enable trace level logging + # SemanticLogger.default_level = :info + # + # # Log to a file + # SemanticLogger.add_appender(file_name: "application.log", formatter: :color) + # + # logger = SemanticLogger["test"] + # logger.info "Hello World" + def initialize(file_name, retry_count: 1, append: true, reopen_period: nil, reopen_count: 0, reopen_size: 0, encoding: Encoding::BINARY, exclusive_lock: false, **args, &block) + if !file_name.is_a?(String) || file_name.empty? + raise(ArgumentError, "SemanticLogging::Appender::File file_name must be a non-empty string") end + @file_name = file_name + @retry_count = retry_count + @file = nil + @append = append + @reopen_period = reopen_period + @reopen_count = reopen_count + @reopen_size = reopen_size + @encoding = encoding + @exclusive_lock = exclusive_lock + @log_count = 0 + @log_size = 0 + @reopen_at = nil + super(**args, &block) end # After forking an active process call #reopen to re-open - # open the file handles etc to resources - # - # Note: This method will only work if :file_name was supplied - # on the initializer. - # If :io was supplied, it will need to be re-opened manually. + # open the file handles etc to resources. def reopen - return unless @file_name + begin + @file&.close + rescue StandardError + nil + end - @log = ::File.open(@file_name, ::File::WRONLY | ::File::APPEND | ::File::CREAT) + self.current_file_name = apply_format_directives(file_name) + if ::File.directory?(file_name) + raise(ArgumentError, "The supplied log file_name: #{current_file_name} is already a directory.") + end + + self.log_count = 0 + if append && reopen_size && ::File.exist?(current_file_name) + self.log_size = ::File.size(current_file_name) + self.log_size = 0 if log_size >= reopen_size + else + self.log_size = 0 + end + + self.reopen_at = reopen_period ? next_reopen_period(reopen_period) : nil + + options = ::File::WRONLY | ::File::CREAT + options |= ::File::APPEND if append + @file = ::File.open(current_file_name, options) # Force all log entries to write immediately without buffering # Allows multiple processes to write to the same log file simultaneously - @log.sync = true - @log.set_encoding(Encoding::BINARY) if @log.respond_to?(:set_encoding) - @log + @file.sync = true + @file.set_encoding(encoding) if @file.respond_to?(:set_encoding) + @file.flock(::File::LOCK_EX) if exclusive_lock + @file end - # Pass log calls to the underlying Rails, log4j or Ruby logger - # trace entries are mapped to debug since :trace is not supported by the - # Ruby or Rails Loggers + # Since only one appender thread will be writing to the file at a time + # it is not necessary to protect access to the file with a semaphore. def log(log) - # Since only one appender thread will be writing to the file at a time - # it is not necessary to protect access to the file with a semaphore - # Allow this logger to filter out log levels lower than it's own - @log.write(formatter.call(log, self) << "\n") + reopen if time_to_reopen? + + count = 0 + begin + message = formatter.call(log, self) << "\n" + @file.write(message) + @log_count += 1 + @log_size += message.size + rescue StandardError => e + if count < retry_count + count += 1 + reopen + retry + end + raise(e) + end true end # Flush all pending logs to disk. - # Waits for all sent documents to be writted to disk + # Waits for all sent documents to be written to disk def flush - @log.flush if @log.respond_to?(:flush) + @file&.flush end - def console_output? - [$stderr, $stdout].include?(@log) + private + + attr_writer :log_count, :log_size, :current_file_name, :reopen_at + + def time_to_reopen? + return true unless @file + + (reopen_count.positive? && (log_count >= reopen_count)) || + (reopen_size.positive? && (log_size >= reopen_size)) || + (reopen_at && (Time.now > reopen_at)) + end + + def apply_format_directives(file_name) + return file_name unless file_name.include?("%") + + file_name.gsub(/%(.)/) { format_directive(Regexp.last_match(1)) } + end + + def format_directive(directive) + case directive + when "p" + $$ + when "n" + SemanticLogger.host.split(".")[0] + when "N" + SemanticLogger.host + when "a" + SemanticLogger.application + when "e" + SemanticLogger.environment + when "D" + Date.today.strftime("%Y%m%d") + when "Y", "C", "y", "m", "d", "j", "U", "W" + Date.today.strftime("%#{directive}") + when "T" + Time.now.strftime("%H%M%S") + when "H", "M", "S" + Time.now.strftime("%#{directive}") + when "%" + "%" + else + raise(ArgumentError, "Format Directive '#{directive}' in file_name: #{file_name} is not supported.") + end + end + + def next_reopen_period(period_string) + return unless period_string + + duration, period = parse_period(period_string) + calculate_reopen_at(duration, period) + end + + def parse_period(period_string) + match = period_string.to_s.downcase.gsub(/\s+/, "").match(/([\d.]+)([mhd])/) + unless match + raise(ArgumentError, + "Invalid period definition: #{period_string}, must begin with an integer, followed by m,h, or d.") + end + + duration = match[1] + period = match[2] + raise(ArgumentError, "Invalid or missing duration in: #{period_string}, must begin with an integer.") unless duration + raise(ArgumentError, "Invalid or missing period in: #{period_string}, must end with m,h, or d.") unless period + + [duration.to_i, period] + end + + # Round down the current time based on the period, then add on the duration for that period + def calculate_reopen_at(duration, period, time = Time.now) + case period + when "m" + Time.new(time.year, time.month, time.day, time.hour, time.min, 0) + (duration * 60) + when "h" + Time.new(time.year, time.month, time.day, time.hour, 0, 0) + (duration * 60 * 60) + when "d" + Time.new(time.year, time.month, time.day, 0, 0, 0) + (duration * 24 * 60 * 60) + else + raise(ArgumentError, "Invalid or missing period in: #{reopen_period}, must end with m,h, or d.") + end end end end end