Skip to content

Commit

Permalink
Add a public API for broadcasting logs:
Browse files Browse the repository at this point in the history
- ## Context

  While working on rails#44695, I
  realised that Broadcasting was still a private API, although it’s
  commonly used. Rafael mentioned that making it public would require
  some refactor because of the original implementation which was hard
  to understand and maintain.

  ### Changing how broadcasting works:

  Broadcasting in a nutshell worked by “transforming” an existing
  logger into a broadcasted one.
  The logger would then be responsible to log and format its own
  messages as well as passing the message along to other logger it
  broadcasts to.

  The problem with this approach was the following:

  - Heavy use of metaprogramming.
  - Accessing the loggers in the broadcast wasn’t possible.
    Removing a logger from the broadcast either.
  - More importantly, modifying the main logger (the one that broadcasts
    logs to the others) wasn’t possible and the main source of
    misunderstanding.

    ```ruby
      logger = Logger.new(STDOUT)
      stderr_logger = Logger.new(STDER))
      logger.extend(AS::Logger.broadcast(stderr_logger))

      logger.level = DEBUG # This modifies the level on all other loggers
      logger.formatter = … # Modified the formatter on all other loggers
    ```

  -> New approach

  To keep the contract unchanged on what Rails.logger returns, the new
  implementation is still a subclass of Logger.
  The difference is that now the broadcast logger just delegate al
  methods to all the other loggers it’s broadcasting to.
  It’s simple and boring and it’s now just an array that gets
  iterated over.

  Now, users can access all loggers inside the broadcast and modify
  them on the fly. They can also remove any logger from the broadcast
  at any time.

  ```ruby
  # Before

  stdout_logger = Logger.new(STDOUT)
  stderr_logger = Logger.new(STDER)
  file_logger = Logger.new(“development.log”)
  stdout_logger.extend(AS::Logger.broadcast(stderr_logger))
  stdout_logger.extend(AS::Logger.broadcast(file_logger))

  # After

  broadcast = BroadcastLogger.new(stdout_logger, stderr_logger, file_logger)
  ```

  I also think that now, it should be more clear for users that the
  broadcast sole job is to pass everything to the whole loggers in
  the broadcast. So there should be no surprise that all loggers in
  the broadcast get their level modified when they call
  `broadcast.level = DEBUG` .

  It’s also easier to wrap your head around more complex setup such
  as broadcasting logs to another broadcast:
  `broadcast.broadcast_to(stdout_logger, other_broadcast)`
  • Loading branch information
Edouard-chin committed Jul 3, 2023
1 parent e366af5 commit 2ea54e1
Show file tree
Hide file tree
Showing 7 changed files with 447 additions and 63 deletions.
1 change: 1 addition & 0 deletions activesupport/lib/active_support.rb
Expand Up @@ -28,6 +28,7 @@
require "active_support/version"
require "active_support/deprecator"
require "active_support/logger"
require "active_support/broadcast_logger"
require "active_support/lazy_load_hooks"
require "active_support/core_ext/date_and_time/compatibility"

Expand Down
204 changes: 204 additions & 0 deletions activesupport/lib/active_support/broadcast_logger.rb
@@ -0,0 +1,204 @@
# frozen_string_literal: true

module ActiveSupport
# The Broadcast logger is a logger used to write messages to multiple IO. It is commonly used
# in development to display messages on STDOUT and also write them to a file (development.log).
# With the Broadcast logger, you can broadcast your logs to a unlimited number of sinks.
#
# The BroadcastLogger acts as a standard logger and all methods you are used to are available.
# However, all the methods on this logger will propagate and be delegated to the other loggers
# that are part of the broadcast.
#
# Broadcasting your logs.
#
# stdout_logger = Logger.new(STDOUT)
# file_logger = Logger.new("development.log")
# broadcast = BroadcastLogger.new(stdout_logger, file_logger)
#
# broadcast.info("Hello world!") # Writes the log to STDOUT and the development.log file.
#
# Add a logger to the broadcast.
#
# stdout_logger = Logger.new(STDOUT)
# broadcast = BroadcastLogger.new(stdout_logger)
# file_logger = Logger.new("development.log")
# broadcast.broadcast_to(file_logger)
#
# broadcast.info("Hello world!") # Writes the log to STDOUT and the development.log file.
#
# Modifying the log level for all broadcasted loggers.
#
# stdout_logger = Logger.new(STDOUT)
# file_logger = Logger.new("development.log")
# broadcast = BroadcastLogger.new(stdout_logger, file_logger)
#
# broadcast.level = Logger::FATAL # Modify the log level for the whole broadcast.
#
# Stop broadcasting log to a sink.
#
# stdout_logger = Logger.new(STDOUT)
# file_logger = Logger.new("development.log")
# broadcast = BroadcastLogger.new(stdout_logger, file_logger)
# broadcast.info("Hello world!") # Writes the log to STDOUT and the development.log file.
#
# broadcast.stop_broadcasting_to(file_logger)
# broadcast.info("Hello world!") # Writes the log *only* to STDOUT.
#
# At least one sink has to be part of the broadcast. Otherwise, your logs will not
# be written anywhere. For instance:
#
# broadcast = BroadcastLogger.new
# broadcast.info("Hello world") # The log message will appear nowhere.
class BroadcastLogger
include ActiveSupport::LoggerSilence

# @return [Array<Logger>] All the logger that are part of this broadcast.
attr_reader :broadcasts

def initialize(*loggers)
@broadcasts = []

broadcast_to(*loggers)
end

# Add logger(s) to the broadcast.
#
# @param loggers [Logger] Loggers that will be part of this broadcast.
#
# @example Broadcast yours logs to STDOUT and STDERR
# broadcast.broadcast_to(Logger.new(STDOUT), Logger.new(STDERR))
def broadcast_to(*loggers)
@broadcasts.concat(loggers)
end

# Remove a logger from the broadcast. When a logger is removed, messages sent to
# the broadcast will no longer be written to its sink.
#
# @param logger [Logger]
def stop_broadcasting_to(logger)
@broadcasts.delete(logger)
end

# Implemented for duck typing.
def progname; end

# Implemented for duck typing.
def level; end

def <<(message)
dispatch { |logger| logger.<<(message) }
end

def add(*args, &block)
dispatch { |logger| logger.add(*args, &block) }
end
alias_method :log, :add

def debug(*args, &block)
dispatch { |logger| logger.debug(*args, &block) }
end

def info(*args, &block)
dispatch { |logger| logger.info(*args, &block) }
end

def warn(*args, &block)
dispatch { |logger| logger.warn(*args, &block) }
end

def error(*args, &block)
dispatch { |logger| logger.error(*args, &block) }
end

def fatal(*args, &block)
dispatch { |logger| logger.fatal(*args, &block) }
end

def unknown(*args, &block)
dispatch { |logger| logger.unknown(*args, &block) }
end

def formatter=(formatter)
dispatch { |logger| logger.formatter = formatter }
end

def progname=(progname)
dispatch { |logger| logger.progname = progname }
end

def level=(level)
dispatch { |logger| logger.level = level }
end
alias_method :sev_threshold=, :level=

def local_level=(level)
dispatch do |logger|
logger.local_level = level if logger.respond_to?(:local_level=)
end
end

def close
dispatch { |logger| logger.close }
end

# @return [Boolean] +True+ if the log level allows entries with severity Logger::DEBUG to be written
# to at least one broadcast. +False+ otherwise.
def debug?
@broadcasts.any? { |logger| logger.debug? }
end

# Sets the log level to Logger::DEBUG for the whole brodcast.
def debug!
dispatch { |logger| logger.debug! }
end

# @return [Boolean] +True+ if the log level allows entries with severity Logger::INFO to be written
# to at least one broadcast. +False+ otherwise.
def info?
@broadcasts.any? { |logger| logger.info? }
end

# Sets the log level to Logger::INFO for the whole brodcast.
def info!
dispatch { |logger| logger.info! }
end

# @return [Boolean] +True+ if the log level allows entries with severity Logger::WARN to be written
# to at least one broadcast. +False+ otherwise.
def warn?
@broadcasts.any? { |logger| logger.warn? }
end

# Sets the log level to Logger::WARN for the whole brodcast.
def warn!
dispatch { |logger| logger.warn! }
end

# @return [Boolean] +True+ if the log level allows entries with severity Logger::ERROR to be written
# to at least one broadcast. +False+ otherwise.
def error?
@broadcasts.any? { |logger| logger.error? }
end

# Sets the log level to Logger::ERROR for the whole brodcast.
def error!
dispatch { |logger| logger.error! }
end

# @return [Boolean] +True+ if the log level allows entries with severity Logger::FATAL to be written
# to at least one broadcast. +False+ otherwise.
def fatal?
@broadcasts.any? { |logger| logger.fatal? }
end

# Sets the log level to Logger::FATAL for the whole brodcast.
def fatal!
dispatch { |logger| logger.fatal! }
end

private
def dispatch(&block)
@broadcasts.each { |logger| block.call(logger) }
end
end
end
58 changes: 0 additions & 58 deletions activesupport/lib/active_support/logger.rb
Expand Up @@ -19,64 +19,6 @@ def self.logger_outputs_to?(logger, *sources)
sources.any? { |source| source == logger_source }
end

# Broadcasts logs to multiple loggers.
def self.broadcast(logger) # :nodoc:
Module.new do
define_method(:add) do |*args, &block|
logger.add(*args, &block)
super(*args, &block)
end

define_method(:<<) do |x|
logger << x
super(x)
end

define_method(:close) do
logger.close
super()
end

define_method(:progname=) do |name|
logger.progname = name
super(name)
end

define_method(:formatter=) do |formatter|
logger.formatter = formatter
super(formatter)
end

define_method(:level=) do |level|
logger.level = level
super(level)
end

define_method(:local_level=) do |level|
logger.local_level = level if logger.respond_to?(:local_level=)
super(level) if respond_to?(:local_level=)
end

define_method(:silence) do |level = Logger::ERROR, &block|
if logger.respond_to?(:silence)
logger.silence(level) do
if defined?(super)
super(level, &block)
else
block.call(self)
end
end
else
if defined?(super)
super(level, &block)
else
block.call(self)
end
end
end
end
end

def initialize(*args, **kwargs)
super
@formatter ||= SimpleFormatter.new
Expand Down

0 comments on commit 2ea54e1

Please sign in to comment.