This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2020-2024, by Samuel Williams.
|
||||
|
||||
# Increase the verbosity of the logger to info.
|
||||
def info
|
||||
require_relative "../lib/console"
|
||||
|
||||
Console.logger.info!
|
||||
end
|
||||
|
||||
# Increase the verbosity of the logger to debug.
|
||||
def debug
|
||||
require_relative "../lib/console"
|
||||
|
||||
Console.logger.debug!
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
# Copyright, 2019, by Bryan Powell.
|
||||
# Copyright, 2020, by Michael Adams.
|
||||
# Copyright, 2021, by Cédric Boutillier.
|
||||
|
||||
require_relative "console/version"
|
||||
require_relative "console/interface"
|
||||
|
||||
module Console
|
||||
Console.extend(Interface)
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2023-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
# This namespace is reserved for logging adapters provided by other gems.
|
||||
module Adapter
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
require_relative "filter"
|
||||
require_relative "output/failure"
|
||||
|
||||
module Console
|
||||
# A general sink which captures all events into a buffer.
|
||||
class Capture
|
||||
def initialize
|
||||
@records = []
|
||||
@verbose = false
|
||||
end
|
||||
|
||||
attr :records
|
||||
|
||||
# @deprecated Use {#records} instead of {#buffer}.
|
||||
alias buffer records
|
||||
|
||||
alias to_a records
|
||||
|
||||
attr :verbose
|
||||
|
||||
def include?(pattern)
|
||||
@records.any? do |record|
|
||||
record[:subject].to_s&.match?(pattern) or record[:message].to_s&.match?(pattern)
|
||||
end
|
||||
end
|
||||
|
||||
def each(&block)
|
||||
@records.each(&block)
|
||||
end
|
||||
|
||||
include Enumerable
|
||||
|
||||
def first
|
||||
@records.first
|
||||
end
|
||||
|
||||
def last
|
||||
@records.last
|
||||
end
|
||||
|
||||
def clear
|
||||
@records.clear
|
||||
end
|
||||
|
||||
def empty?
|
||||
@records.empty?
|
||||
end
|
||||
|
||||
def verbose!(value = true)
|
||||
@verbose = value
|
||||
end
|
||||
|
||||
def verbose?
|
||||
@verbose
|
||||
end
|
||||
|
||||
def call(subject = nil, *arguments, severity: UNKNOWN, event: nil, **options, &block)
|
||||
record = {
|
||||
time: ::Time.now.iso8601,
|
||||
severity: severity,
|
||||
**options,
|
||||
}
|
||||
|
||||
if subject
|
||||
record[:subject] = subject
|
||||
end
|
||||
|
||||
if event
|
||||
record[:event] = event.to_hash
|
||||
end
|
||||
|
||||
if arguments.any?
|
||||
record[:arguments] = arguments
|
||||
end
|
||||
|
||||
if annotation = Fiber.current.annotation
|
||||
record[:annotation] = annotation
|
||||
end
|
||||
|
||||
if block_given?
|
||||
if block.arity.zero?
|
||||
record[:message] = yield
|
||||
else
|
||||
buffer = StringIO.new
|
||||
yield buffer
|
||||
record[:message] = buffer.string
|
||||
end
|
||||
else
|
||||
record[:message] = arguments.join(" ")
|
||||
end
|
||||
|
||||
@records << record
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2022, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Clock
|
||||
def self.formatted_duration(duration)
|
||||
if duration < 60.0
|
||||
return "#{duration.round(2)}s"
|
||||
end
|
||||
|
||||
duration /= 60.0
|
||||
|
||||
if duration < 60.0
|
||||
return "#{duration.floor}m"
|
||||
end
|
||||
|
||||
duration /= 60.0
|
||||
|
||||
if duration < 24.0
|
||||
return "#{duration.floor}h"
|
||||
end
|
||||
|
||||
duration /= 24.0
|
||||
|
||||
return "#{duration.floor}d"
|
||||
end
|
||||
|
||||
# Get the current elapsed monotonic time.
|
||||
def self.now
|
||||
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2022-2024, by Samuel Williams.
|
||||
|
||||
require "logger"
|
||||
|
||||
module Console
|
||||
module Compatible
|
||||
# A compatible interface for {::Logger} which can be used with {Console}.
|
||||
class Logger < ::Logger
|
||||
class LogDevice
|
||||
def initialize(subject, output)
|
||||
@subject = subject
|
||||
@output = output
|
||||
end
|
||||
|
||||
def write(message)
|
||||
@output.call(@subject, message)
|
||||
end
|
||||
|
||||
def call(*arguments, **options)
|
||||
@output.call(*arguments, **options)
|
||||
end
|
||||
|
||||
def reopen
|
||||
end
|
||||
|
||||
def close
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(subject, output = Console)
|
||||
super(nil)
|
||||
|
||||
@progname = subject
|
||||
@logdev = LogDevice.new(subject, output)
|
||||
end
|
||||
|
||||
def add(severity, message = nil, progname = nil)
|
||||
severity ||= UNKNOWN
|
||||
|
||||
if @logdev.nil? or severity < level
|
||||
return true
|
||||
end
|
||||
|
||||
if progname.nil?
|
||||
progname = @progname
|
||||
end
|
||||
|
||||
if message.nil?
|
||||
if block_given?
|
||||
message = yield
|
||||
else
|
||||
message = progname
|
||||
progname = @progname
|
||||
end
|
||||
end
|
||||
|
||||
@logdev.call(
|
||||
progname, message,
|
||||
severity: format_severity(severity)
|
||||
)
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
def format_severity(value)
|
||||
super.downcase.to_sym
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
require_relative "event/spawn"
|
||||
require_relative "event/failure"
|
||||
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
# Copyright, 2021, by Robert Schulze.
|
||||
# Copyright, 2024, by Patrik Wenger.
|
||||
|
||||
require_relative "generic"
|
||||
|
||||
module Console
|
||||
module Event
|
||||
# Represents a failure event.
|
||||
#
|
||||
# ```ruby
|
||||
# Console::Event::Failure.for(exception).emit(self)
|
||||
# ```
|
||||
class Failure < Generic
|
||||
def self.default_root
|
||||
Dir.getwd
|
||||
rescue # e.g. Errno::EMFILE
|
||||
nil
|
||||
end
|
||||
|
||||
def self.for(exception)
|
||||
self.new(exception, self.default_root)
|
||||
end
|
||||
|
||||
def self.log(subject, exception, **options)
|
||||
Console.error(subject, **self.for(exception).to_hash, **options)
|
||||
end
|
||||
|
||||
attr_reader :exception
|
||||
|
||||
def initialize(exception, root = Dir.getwd)
|
||||
@exception = exception
|
||||
@root = root
|
||||
end
|
||||
|
||||
def to_hash
|
||||
Hash.new.tap do |hash|
|
||||
hash[:type] = :failure
|
||||
hash[:root] = @root if @root
|
||||
extract(@exception, hash)
|
||||
end
|
||||
end
|
||||
|
||||
def emit(*arguments, **options)
|
||||
options[:severity] ||= :error
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract(exception, hash)
|
||||
hash[:class] = exception.class.name
|
||||
|
||||
if exception.respond_to?(:detailed_message)
|
||||
message = exception.detailed_message
|
||||
|
||||
# We want to remove the trailling exception class as we format it differently:
|
||||
message.sub!(/\s*\(.*?\)$/, "")
|
||||
|
||||
hash[:message] = message
|
||||
else
|
||||
hash[:message] = exception.message
|
||||
end
|
||||
|
||||
hash[:backtrace] = exception.backtrace
|
||||
|
||||
if cause = exception.cause
|
||||
hash[:cause] = Hash.new.tap do |cause_hash|
|
||||
extract(cause, cause_hash)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Event
|
||||
class Generic
|
||||
def as_json(...)
|
||||
to_hash
|
||||
end
|
||||
|
||||
def to_json(...)
|
||||
JSON.generate(as_json, ...)
|
||||
end
|
||||
|
||||
def to_s
|
||||
to_json
|
||||
end
|
||||
|
||||
def emit(*arguments, **options)
|
||||
Console.call(*arguments, event: self, **options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
require_relative "generic"
|
||||
require_relative "../clock"
|
||||
|
||||
module Console
|
||||
module Event
|
||||
# Represents a spawn event.
|
||||
#
|
||||
# ```ruby
|
||||
# Console.info(self, **Console::Event::Spawn.for("ls", "-l"))
|
||||
#
|
||||
# event = Console::Event::Spawn.for("ls", "-l")
|
||||
# event.status = Process.wait
|
||||
# ```
|
||||
class Spawn < Generic
|
||||
def self.for(*arguments, **options)
|
||||
# Extract out the command environment:
|
||||
if arguments.first.is_a?(Hash)
|
||||
environment = arguments.shift
|
||||
self.new(environment, arguments, options)
|
||||
else
|
||||
self.new(nil, arguments, options)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(environment, arguments, options)
|
||||
@environment = environment
|
||||
@arguments = arguments
|
||||
@options = options
|
||||
|
||||
@start_time = Clock.now
|
||||
|
||||
@end_time = nil
|
||||
@status = nil
|
||||
end
|
||||
|
||||
def duration
|
||||
if @end_time
|
||||
@end_time - @start_time
|
||||
end
|
||||
end
|
||||
|
||||
def to_hash
|
||||
Hash.new.tap do |hash|
|
||||
hash[:type] = :spawn
|
||||
hash[:environment] = @environment if @environment&.any?
|
||||
hash[:arguments] = @arguments if @arguments&.any?
|
||||
hash[:options] = @options if @options&.any?
|
||||
|
||||
hash[:status] = @status.to_i if @status
|
||||
|
||||
if duration = self.duration
|
||||
hash[:duration] = duration
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def emit(*arguments, **options)
|
||||
options[:severity] ||= :info
|
||||
super
|
||||
end
|
||||
|
||||
def status=(status)
|
||||
@end_time = Time.now
|
||||
@status = status
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,159 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
# Copyright, 2019, by Bryan Powell.
|
||||
# Copyright, 2020, by Michael Adams.
|
||||
# Copyright, 2021, by Robert Schulze.
|
||||
|
||||
module Console
|
||||
UNKNOWN = :unknown
|
||||
|
||||
class Filter
|
||||
if Object.const_defined?(:Ractor) and RUBY_VERSION >= "3.1"
|
||||
def self.define_immutable_method(name, &block)
|
||||
block = Ractor.make_shareable(block)
|
||||
self.define_method(name, &block)
|
||||
end
|
||||
else
|
||||
def self.define_immutable_method(name, &block)
|
||||
define_method(name, &block)
|
||||
end
|
||||
end
|
||||
|
||||
def self.[] **levels
|
||||
klass = Class.new(self)
|
||||
minimum_level, maximum_level = levels.values.minmax
|
||||
|
||||
klass.instance_exec do
|
||||
const_set(:LEVELS, levels.freeze)
|
||||
const_set(:MINIMUM_LEVEL, minimum_level)
|
||||
const_set(:MAXIMUM_LEVEL, maximum_level)
|
||||
|
||||
levels.each do |name, level|
|
||||
const_set(name.to_s.upcase, level)
|
||||
|
||||
define_immutable_method(name) do |subject = nil, *arguments, **options, &block|
|
||||
if self.enabled?(subject, level)
|
||||
@output.call(subject, *arguments, severity: name, **@options, **options, &block)
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
define_immutable_method("#{name}!") do
|
||||
@level = level
|
||||
end
|
||||
|
||||
define_immutable_method("#{name}?") do
|
||||
@level <= level
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return klass
|
||||
end
|
||||
|
||||
def initialize(output, verbose: true, level: self.class::DEFAULT_LEVEL, **options)
|
||||
@output = output
|
||||
@verbose = verbose
|
||||
@level = level
|
||||
|
||||
@subjects = {}
|
||||
|
||||
@options = options
|
||||
end
|
||||
|
||||
def with(level: @level, verbose: @verbose, **options)
|
||||
dup.tap do |logger|
|
||||
logger.level = level
|
||||
logger.verbose! if verbose
|
||||
logger.options = @options.merge(options)
|
||||
end
|
||||
end
|
||||
|
||||
attr_accessor :output
|
||||
attr :verbose
|
||||
attr :level
|
||||
|
||||
attr :subjects
|
||||
|
||||
attr_accessor :options
|
||||
|
||||
def level= level
|
||||
if level.is_a? Symbol
|
||||
@level = self.class::LEVELS[level]
|
||||
else
|
||||
@level = level
|
||||
end
|
||||
end
|
||||
|
||||
def verbose!(value = true)
|
||||
@verbose = value
|
||||
@output.verbose!(value)
|
||||
end
|
||||
|
||||
def off!
|
||||
@level = self.class::MAXIMUM_LEVEL + 1
|
||||
end
|
||||
|
||||
def all!
|
||||
@level = self.class::MINIMUM_LEVEL - 1
|
||||
end
|
||||
|
||||
def filter(subject, level)
|
||||
unless subject.is_a?(Module)
|
||||
raise ArgumentError, "Expected a class, got #{subject.inspect}"
|
||||
end
|
||||
|
||||
@subjects[subject] = level
|
||||
end
|
||||
|
||||
# You can enable and disable logging for classes. This function checks if logging for a given subject is enabled.
|
||||
# @param subject [Object] the subject to check.
|
||||
def enabled?(subject, level = self.class::MINIMUM_LEVEL)
|
||||
subject = subject.class unless subject.is_a?(Module)
|
||||
|
||||
if specific_level = @subjects[subject]
|
||||
return level >= specific_level
|
||||
end
|
||||
|
||||
if level >= @level
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
# Enable specific log level for the given class.
|
||||
# @parameter name [Module] The class to enable.
|
||||
def enable(subject, level = self.class::MINIMUM_LEVEL)
|
||||
# Set the filter level of logging for a given subject which passes all log messages:
|
||||
filter(subject, level)
|
||||
end
|
||||
|
||||
def disable(subject)
|
||||
# Set the filter level of the logging for a given subject which filters all log messages:
|
||||
filter(subject, self.class::MAXIMUM_LEVEL + 1)
|
||||
end
|
||||
|
||||
# Clear any specific filters for the given class.
|
||||
# @parameter name [Module] The class to disable.
|
||||
def clear(subject)
|
||||
unless subject.is_a?(Module)
|
||||
raise ArgumentError, "Expected a class, got #{subject.inspect}"
|
||||
end
|
||||
|
||||
@subjects.delete(subject)
|
||||
end
|
||||
|
||||
def call(subject, *arguments, **options, &block)
|
||||
severity = options[:severity] || UNKNOWN
|
||||
level = self.class::LEVELS[severity]
|
||||
|
||||
if self.enabled?(subject, level)
|
||||
@output.call(subject, *arguments, **options, &block)
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2023-2024, by Samuel Williams.
|
||||
|
||||
require_relative "format/safe"
|
||||
|
||||
module Console
|
||||
module Format
|
||||
def self.default
|
||||
Safe.new(format: ::JSON)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,136 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2023-2024, by Samuel Williams.
|
||||
|
||||
require "json"
|
||||
|
||||
module Console
|
||||
module Format
|
||||
# This class is used to safely dump objects.
|
||||
# It will attempt to dump the object using the given format, but if it fails, it will generate a safe version of the object.
|
||||
class Safe
|
||||
def initialize(format: ::JSON, limit: 8, encoding: ::Encoding::UTF_8)
|
||||
@format = format
|
||||
@limit = limit
|
||||
@encoding = encoding
|
||||
end
|
||||
|
||||
def dump(object)
|
||||
@format.dump(object, @limit)
|
||||
rescue SystemStackError, StandardError => error
|
||||
@format.dump(safe_dump(object, error))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_backtrace(error)
|
||||
frames = error.backtrace
|
||||
filtered = {}
|
||||
filtered_count = nil
|
||||
skipped = nil
|
||||
|
||||
frames = frames.filter_map do |frame|
|
||||
if filtered[frame]
|
||||
if filtered_count == nil
|
||||
filtered_count = 1
|
||||
skipped = frame.dup
|
||||
else
|
||||
filtered_count += 1
|
||||
nil
|
||||
end
|
||||
else
|
||||
if skipped
|
||||
if filtered_count > 1
|
||||
skipped.replace("[... #{filtered_count} frames skipped ...]")
|
||||
end
|
||||
|
||||
filtered_count = nil
|
||||
skipped = nil
|
||||
end
|
||||
|
||||
filtered[frame] = true
|
||||
frame
|
||||
end
|
||||
end
|
||||
|
||||
if skipped && filtered_count > 1
|
||||
skipped.replace("[... #{filtered_count} frames skipped ...]")
|
||||
end
|
||||
|
||||
return frames
|
||||
end
|
||||
|
||||
def safe_dump(object, error)
|
||||
object = safe_dump_recurse(object)
|
||||
|
||||
object[:truncated] = true
|
||||
object[:error] = {
|
||||
class: safe_dump_recurse(error.class.name),
|
||||
message: safe_dump_recurse(error.message),
|
||||
backtrace: safe_dump_recurse(filter_backtrace(error)),
|
||||
}
|
||||
|
||||
return object
|
||||
end
|
||||
|
||||
def replacement_for(object)
|
||||
case object
|
||||
when Array
|
||||
"[...]"
|
||||
when Hash
|
||||
"{...}"
|
||||
else
|
||||
"..."
|
||||
end
|
||||
end
|
||||
|
||||
def default_objects
|
||||
Hash.new.compare_by_identity
|
||||
end
|
||||
|
||||
# This will recursively generate a safe version of the object.
|
||||
# Nested hashes and arrays will be transformed recursively.
|
||||
# Strings will be encoded with the given encoding.
|
||||
# Primitive values will be returned as-is.
|
||||
# Other values will be converted using `as_json` if available, otherwise `to_s`.
|
||||
def safe_dump_recurse(object, limit = @limit, objects = default_objects)
|
||||
if limit <= 0 || objects[object]
|
||||
return replacement_for(object)
|
||||
end
|
||||
|
||||
case object
|
||||
when Hash
|
||||
objects[object] = true
|
||||
|
||||
object.to_h do |key, value|
|
||||
[
|
||||
String(key).encode(@encoding, invalid: :replace, undef: :replace),
|
||||
safe_dump_recurse(value, limit - 1, objects)
|
||||
]
|
||||
end
|
||||
when Array
|
||||
objects[object] = true
|
||||
|
||||
object.map do |value|
|
||||
safe_dump_recurse(value, limit - 1, objects)
|
||||
end
|
||||
when String
|
||||
object.encode(@encoding, invalid: :replace, undef: :replace)
|
||||
when Numeric, TrueClass, FalseClass, NilClass
|
||||
object
|
||||
else
|
||||
objects[object] = true
|
||||
|
||||
# We could do something like this but the chance `as_json` will blow up.
|
||||
# We'd need to be extremely careful about it.
|
||||
# if object.respond_to?(:as_json)
|
||||
# safe_dump_recurse(object.as_json, limit - 1, objects)
|
||||
# else
|
||||
|
||||
safe_dump_recurse(object.to_s, limit - 1, objects)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2024-2025, by Samuel Williams.
|
||||
|
||||
require_relative "logger"
|
||||
|
||||
module Console
|
||||
# The public logger interface.
|
||||
module Interface
|
||||
# Get the current logger instance.
|
||||
def logger
|
||||
Logger.instance
|
||||
end
|
||||
|
||||
# Set the current logger instance.
|
||||
#
|
||||
# The current logger instance is assigned per-fiber.
|
||||
def logger= instance
|
||||
Logger.instance= instance
|
||||
end
|
||||
|
||||
# Emit a debug log message.
|
||||
def debug(...)
|
||||
Logger.instance.debug(...)
|
||||
end
|
||||
|
||||
# Emit an informational log message.
|
||||
def info(...)
|
||||
Logger.instance.info(...)
|
||||
end
|
||||
|
||||
# Emit a warning log message.
|
||||
def warn(...)
|
||||
Logger.instance.warn(...)
|
||||
end
|
||||
|
||||
# Emit an error log message.
|
||||
def error(...)
|
||||
Logger.instance.error(...)
|
||||
end
|
||||
|
||||
# Emit a fatal log message.
|
||||
def fatal(...)
|
||||
Logger.instance.fatal(...)
|
||||
end
|
||||
|
||||
# Emit a log message with arbitrary arguments and options.
|
||||
def call(...)
|
||||
Logger.instance.call(...)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
# Copyright, 2021, by Bryan Powell.
|
||||
# Copyright, 2021, by Robert Schulze.
|
||||
|
||||
require_relative "output"
|
||||
require_relative "output/failure"
|
||||
|
||||
require_relative "filter"
|
||||
require_relative "event"
|
||||
require_relative "resolver"
|
||||
require_relative "progress"
|
||||
|
||||
require "fiber/local"
|
||||
|
||||
module Console
|
||||
class Logger < Filter[debug: 0, info: 1, warn: 2, error: 3, fatal: 4]
|
||||
extend Fiber::Local
|
||||
|
||||
# Set the default log level based on `$DEBUG` and `$VERBOSE`.
|
||||
# You can also specify CONSOLE_LEVEL=debug or CONSOLE_LEVEL=info in environment.
|
||||
# https://mislav.net/2011/06/ruby-verbose-mode/ has more details about how it all fits together.
|
||||
def self.default_log_level(env = ENV)
|
||||
if level = env["CONSOLE_LEVEL"]
|
||||
LEVELS[level.to_sym] || level.to_i
|
||||
elsif $DEBUG
|
||||
DEBUG
|
||||
elsif $VERBOSE.nil?
|
||||
WARN
|
||||
else
|
||||
INFO
|
||||
end
|
||||
end
|
||||
|
||||
# Controls verbose output using `$VERBOSE`.
|
||||
def self.verbose?(env = ENV)
|
||||
!$VERBOSE.nil? || env["CONSOLE_VERBOSE"]
|
||||
end
|
||||
|
||||
def self.default_logger(output = $stderr, env = ENV, **options)
|
||||
if options[:verbose].nil?
|
||||
options[:verbose] = self.verbose?(env)
|
||||
end
|
||||
|
||||
if options[:level].nil?
|
||||
options[:level] = self.default_log_level(env)
|
||||
end
|
||||
|
||||
output = Output.new(output, env, **options)
|
||||
|
||||
logger = self.new(output, **options)
|
||||
|
||||
Resolver.default_resolver(logger)
|
||||
|
||||
return logger
|
||||
end
|
||||
|
||||
def self.local
|
||||
self.default_logger
|
||||
end
|
||||
|
||||
DEFAULT_LEVEL = 1
|
||||
|
||||
def initialize(output, **options)
|
||||
# This is the expected default behaviour, but it may be nice to have a way to override it.
|
||||
output = Output::Failure.new(output, **options)
|
||||
|
||||
super(output, **options)
|
||||
end
|
||||
|
||||
def progress(subject, total, **options)
|
||||
options[:severity] ||= :info
|
||||
|
||||
Progress.new(subject, total, **options)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
|
||||
require_relative "output/default"
|
||||
require_relative "output/serialized"
|
||||
require_relative "output/terminal"
|
||||
require_relative "output/null"
|
||||
|
||||
module Console
|
||||
module Output
|
||||
def self.new(output = nil, env = ENV, **options)
|
||||
if names = env["CONSOLE_OUTPUT"]
|
||||
names = names.split(",").reverse
|
||||
|
||||
names.inject(output) do |output, name|
|
||||
Output.const_get(name).new(output, **options)
|
||||
end
|
||||
else
|
||||
return Output::Default.new(output, **options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
|
||||
require_relative "terminal"
|
||||
require_relative "serialized"
|
||||
require_relative "failure"
|
||||
|
||||
module Console
|
||||
module Output
|
||||
module Default
|
||||
def self.new(output, **options)
|
||||
output ||= $stderr
|
||||
|
||||
if output.tty?
|
||||
output = Terminal.new(output, **options)
|
||||
else
|
||||
output = Serialized.new(output, **options)
|
||||
end
|
||||
|
||||
return output
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2024-2025, by Samuel Williams.
|
||||
|
||||
require_relative "wrapper"
|
||||
require_relative "../event/failure"
|
||||
|
||||
module Console
|
||||
module Output
|
||||
# A wrapper for outputting failure messages, which can include exceptions.
|
||||
class Failure < Wrapper
|
||||
def initialize(output, **options)
|
||||
super(output, **options)
|
||||
end
|
||||
|
||||
# The exception must be either the last argument or passed as an option.
|
||||
def call(subject = nil, *arguments, exception: nil, **options, &block)
|
||||
if exception.nil?
|
||||
last = arguments.last
|
||||
if last.is_a?(Exception)
|
||||
options[:event] = Event::Failure.for(last)
|
||||
end
|
||||
elsif exception.is_a?(Exception)
|
||||
options[:event] = Event::Failure.for(exception)
|
||||
else
|
||||
# We don't know what this is, so we just pass it through:
|
||||
options[:exception] = exception
|
||||
end
|
||||
|
||||
super(subject, *arguments, **options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2023-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Output
|
||||
class Null
|
||||
def initialize(...)
|
||||
end
|
||||
|
||||
def last_output
|
||||
self
|
||||
end
|
||||
|
||||
def call(...)
|
||||
# Do nothing.
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
|
||||
require_relative "wrapper"
|
||||
|
||||
module Console
|
||||
module Output
|
||||
class Sensitive < Wrapper
|
||||
REDACT = /
|
||||
phone
|
||||
| email
|
||||
| full_?name
|
||||
| first_?name
|
||||
| last_?name
|
||||
|
||||
| device_name
|
||||
| user_agent
|
||||
|
||||
| zip
|
||||
| address
|
||||
| location
|
||||
| latitude
|
||||
| longitude
|
||||
|
||||
| ip
|
||||
| gps
|
||||
|
||||
| sex
|
||||
| gender
|
||||
|
||||
| token
|
||||
| password
|
||||
/xi
|
||||
|
||||
def initialize(output, redact: REDACT, **options)
|
||||
super(output, **options)
|
||||
|
||||
@redact = redact
|
||||
end
|
||||
|
||||
def redact?(text)
|
||||
text.match?(@redact)
|
||||
end
|
||||
|
||||
def redact_hash(arguments, filter)
|
||||
arguments.transform_values do |value|
|
||||
redact(value, filter)
|
||||
end
|
||||
end
|
||||
|
||||
def redact_array(array, filter)
|
||||
array.map do |value|
|
||||
redact(value, filter)
|
||||
end
|
||||
end
|
||||
|
||||
def redact(argument, filter)
|
||||
case argument
|
||||
when String
|
||||
if filter
|
||||
filter.call(argument)
|
||||
elsif redact?(argument)
|
||||
"[REDACTED]"
|
||||
else
|
||||
argument
|
||||
end
|
||||
when Array
|
||||
redact_array(argument, filter)
|
||||
when Hash
|
||||
redact_hash(argument, filter)
|
||||
else
|
||||
redact(argument.to_s, filter)
|
||||
end
|
||||
end
|
||||
|
||||
class Filter
|
||||
def initialize(substitutions)
|
||||
@substitutions = substitutions
|
||||
@pattern = Regexp.union(substitutions.keys)
|
||||
end
|
||||
|
||||
def call(text)
|
||||
text.gsub(@pattern, @substitutions)
|
||||
end
|
||||
end
|
||||
|
||||
def call(subject = nil, *arguments, sensitive: true, **options, &block)
|
||||
if sensitive
|
||||
if sensitive.respond_to?(:call)
|
||||
filter = sensitive
|
||||
elsif sensitive.is_a?(Hash)
|
||||
filter = Filter.new(sensitive)
|
||||
end
|
||||
|
||||
subject = redact(subject, filter)
|
||||
arguments = redact_array(arguments, filter)
|
||||
end
|
||||
|
||||
super(subject, *arguments, **options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2025, by Samuel Williams.
|
||||
|
||||
require_relative "../format"
|
||||
require "time"
|
||||
require "fiber/annotation"
|
||||
|
||||
module Console
|
||||
module Output
|
||||
class Serialized
|
||||
def initialize(io, format: Format.default, **options)
|
||||
@io = io
|
||||
@format = format
|
||||
end
|
||||
|
||||
# This a final output that then writes to an IO object.
|
||||
def last_output
|
||||
self
|
||||
end
|
||||
|
||||
attr :io
|
||||
attr :format
|
||||
|
||||
def dump(record)
|
||||
@format.dump(record)
|
||||
end
|
||||
|
||||
def call(subject = nil, *arguments, severity: UNKNOWN, **options, &block)
|
||||
record = {
|
||||
time: Time.now.iso8601,
|
||||
severity: severity,
|
||||
oid: subject.object_id,
|
||||
pid: Process.pid,
|
||||
}
|
||||
|
||||
# We want to log just a brief subject:
|
||||
if subject.is_a?(String)
|
||||
record[:subject] = subject
|
||||
elsif subject.is_a?(Module)
|
||||
record[:subject] = subject.name
|
||||
else
|
||||
record[:subject] = subject.class.name
|
||||
end
|
||||
|
||||
if annotation = Fiber.current.annotation
|
||||
record[:annotation] = annotation
|
||||
end
|
||||
|
||||
message = arguments
|
||||
|
||||
if block_given?
|
||||
if block.arity.zero?
|
||||
message << yield
|
||||
else
|
||||
buffer = StringIO.new
|
||||
yield buffer
|
||||
message << buffer.string
|
||||
end
|
||||
end
|
||||
|
||||
if message.size == 1
|
||||
record[:message] = message.first
|
||||
elsif message.any?
|
||||
record[:message] = message
|
||||
end
|
||||
|
||||
record.update(options)
|
||||
|
||||
@io.write(self.dump(record) << "\n")
|
||||
end
|
||||
end
|
||||
|
||||
JSON = Serialized
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2022-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Output
|
||||
class Split
|
||||
def self.[](*outputs)
|
||||
self.new(outputs)
|
||||
end
|
||||
|
||||
def initialize(outputs)
|
||||
@outputs = outputs
|
||||
end
|
||||
|
||||
def verbose!(value = true)
|
||||
@outputs.each{|output| output.verbose!(value)}
|
||||
end
|
||||
|
||||
def call(...)
|
||||
@outputs.each do |output|
|
||||
output.call(...)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,257 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
# Copyright, 2021, by Robert Schulze.
|
||||
|
||||
require_relative "../clock"
|
||||
require_relative "../terminal"
|
||||
|
||||
require "json"
|
||||
require "fiber"
|
||||
require "fiber/annotation"
|
||||
require "stringio"
|
||||
|
||||
module Console
|
||||
module Output
|
||||
class Terminal
|
||||
class Buffer < StringIO
|
||||
def initialize(prefix = nil)
|
||||
@prefix = prefix
|
||||
|
||||
super()
|
||||
end
|
||||
|
||||
attr :prefix
|
||||
|
||||
def puts(*args, prefix: @prefix)
|
||||
args.each do |arg|
|
||||
self.write(prefix) if prefix
|
||||
super(arg)
|
||||
end
|
||||
end
|
||||
|
||||
alias << puts
|
||||
end
|
||||
|
||||
# This, and all related methods, is considered private.
|
||||
CONSOLE_START_AT = "CONSOLE_START_AT"
|
||||
|
||||
# Exports CONSOLE_START which can be used to synchronize the start times of all child processes when they log using delta time.
|
||||
def self.start_at!(environment = ENV)
|
||||
if time_string = environment[CONSOLE_START_AT]
|
||||
start_at = Time.parse(time_string) rescue nil
|
||||
end
|
||||
|
||||
unless start_at
|
||||
start_at = Time.now
|
||||
environment[CONSOLE_START_AT] = start_at.to_s
|
||||
end
|
||||
|
||||
return start_at
|
||||
end
|
||||
|
||||
def initialize(output, verbose: nil, start_at: Terminal.start_at!, format: nil, **options)
|
||||
@io = output
|
||||
@start_at = start_at
|
||||
|
||||
@terminal = format.nil? ? Console::Terminal.for(@io) : format.new(@io)
|
||||
|
||||
if verbose.nil?
|
||||
@verbose = !@terminal.colors?
|
||||
else
|
||||
@verbose = verbose
|
||||
end
|
||||
|
||||
@terminal[:logger_suffix] ||= @terminal.style(:white, nil, :faint)
|
||||
@terminal[:subject] ||= @terminal.style(nil, nil, :bold)
|
||||
@terminal[:debug] = @terminal.style(:cyan)
|
||||
@terminal[:info] = @terminal.style(:green)
|
||||
@terminal[:warn] = @terminal.style(:yellow)
|
||||
@terminal[:error] = @terminal.style(:red)
|
||||
@terminal[:fatal] = @terminal[:error]
|
||||
|
||||
@terminal[:annotation] = @terminal.reset
|
||||
@terminal[:value] = @terminal.style(:blue)
|
||||
|
||||
@formatters = {}
|
||||
self.register_formatters
|
||||
end
|
||||
|
||||
# This a final output that then writes to an IO object.
|
||||
def last_output
|
||||
self
|
||||
end
|
||||
|
||||
attr :io
|
||||
|
||||
attr_accessor :verbose
|
||||
|
||||
attr :start
|
||||
attr :terminal
|
||||
|
||||
def verbose!(value = true)
|
||||
@verbose = value
|
||||
end
|
||||
|
||||
def register_formatters(namespace = Console::Terminal::Formatter)
|
||||
namespace.constants.each do |name|
|
||||
formatter = namespace.const_get(name)
|
||||
@formatters[formatter::KEY] = formatter.new(@terminal)
|
||||
end
|
||||
end
|
||||
|
||||
UNKNOWN = :unknown
|
||||
|
||||
def call(subject = nil, *arguments, name: nil, severity: UNKNOWN, event: nil, **options, &block)
|
||||
width = @terminal.width
|
||||
|
||||
prefix = build_prefix(name || severity.to_s)
|
||||
indent = " " * prefix.size
|
||||
|
||||
buffer = Buffer.new("#{indent}| ")
|
||||
indent_size = buffer.prefix.size
|
||||
|
||||
format_subject(severity, prefix, subject, buffer)
|
||||
|
||||
arguments.each do |argument|
|
||||
format_argument(argument, buffer)
|
||||
end
|
||||
|
||||
if block_given?
|
||||
if block.arity.zero?
|
||||
format_argument(yield, buffer)
|
||||
else
|
||||
yield(buffer, @terminal)
|
||||
end
|
||||
end
|
||||
|
||||
if event
|
||||
format_event(event, buffer, width - indent_size)
|
||||
end
|
||||
|
||||
if options&.any?
|
||||
format_options(options, buffer)
|
||||
end
|
||||
|
||||
@io.write buffer.string
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def format_event(event, buffer, width)
|
||||
event = event.to_hash
|
||||
type = event[:type]
|
||||
|
||||
if formatter = @formatters[type]
|
||||
formatter.format(event, buffer, verbose: @verbose, width: width)
|
||||
else
|
||||
format_value(::JSON.pretty_generate(event), buffer)
|
||||
end
|
||||
end
|
||||
|
||||
def format_options(options, output)
|
||||
format_value(::JSON.pretty_generate(options), output)
|
||||
end
|
||||
|
||||
def format_argument(argument, output)
|
||||
argument.to_s.each_line do |line|
|
||||
output.puts line
|
||||
end
|
||||
end
|
||||
|
||||
def format_subject(severity, prefix, subject, buffer)
|
||||
if subject.is_a?(String)
|
||||
format_string_subject(severity, prefix, subject, buffer)
|
||||
elsif subject.is_a?(Module)
|
||||
format_string_subject(severity, prefix, subject.to_s, buffer)
|
||||
else
|
||||
format_object_subject(severity, prefix, subject, buffer)
|
||||
end
|
||||
end
|
||||
|
||||
def default_suffix(object = nil)
|
||||
buffer = +""
|
||||
|
||||
if @verbose
|
||||
if annotation = Fiber.current.annotation
|
||||
# While typically annotations should be strings, that is not always the case.
|
||||
annotation = annotation.to_s
|
||||
|
||||
# If the annotation is empty, we don't want to print it, as it will look like a formatting bug.
|
||||
if annotation.size > 0
|
||||
buffer << ": #{@terminal[:annotation]}#{annotation}#{@terminal.reset}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
buffer << " #{@terminal[:logger_suffix]}"
|
||||
|
||||
if object
|
||||
buffer << "[oid=0x#{object.object_id.to_s(16)}] "
|
||||
end
|
||||
|
||||
buffer << "[ec=0x#{Fiber.current.object_id.to_s(16)}] [pid=#{Process.pid}] [#{::Time.now}]#{@terminal.reset}"
|
||||
|
||||
return buffer
|
||||
end
|
||||
|
||||
def format_object_subject(severity, prefix, subject, output)
|
||||
prefix_style = @terminal[severity]
|
||||
|
||||
if @verbose
|
||||
suffix = default_suffix(subject)
|
||||
end
|
||||
|
||||
prefix = "#{prefix_style}#{prefix}:#{@terminal.reset} "
|
||||
|
||||
output.puts "#{@terminal[:subject]}#{subject.class}#{@terminal.reset}#{suffix}", prefix: prefix
|
||||
end
|
||||
|
||||
def format_string_subject(severity, prefix, subject, output)
|
||||
prefix_style = @terminal[severity]
|
||||
|
||||
if @verbose
|
||||
suffix = default_suffix
|
||||
end
|
||||
|
||||
prefix = "#{prefix_style}#{prefix}:#{@terminal.reset} "
|
||||
|
||||
output.puts "#{@terminal[:subject]}#{subject}#{@terminal.reset}#{suffix}", prefix: prefix
|
||||
end
|
||||
|
||||
def format_value(value, output)
|
||||
string = value.to_s
|
||||
|
||||
string.each_line do |line|
|
||||
line.chomp!
|
||||
output.puts "#{@terminal[:value]}#{line}#{@terminal.reset}"
|
||||
end
|
||||
end
|
||||
|
||||
def time_offset_prefix
|
||||
Clock.formatted_duration(Time.now - @start_at).rjust(6)
|
||||
end
|
||||
|
||||
def build_prefix(name)
|
||||
if @verbose
|
||||
"#{time_offset_prefix} #{name.rjust(8)}"
|
||||
else
|
||||
time_offset_prefix
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module Text
|
||||
def self.new(output, **options)
|
||||
Terminal.new(output, format: Console::Terminal::Text, **options)
|
||||
end
|
||||
end
|
||||
|
||||
module XTerm
|
||||
def self.new(output, **options)
|
||||
Terminal.new(output, format: Console::Terminal::XTerm, **options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Output
|
||||
class Wrapper
|
||||
def initialize(delegate, **options)
|
||||
@delegate = delegate
|
||||
end
|
||||
|
||||
attr :delegate
|
||||
|
||||
def last_output
|
||||
@delegate.last_output
|
||||
end
|
||||
|
||||
def verbose!(value = true)
|
||||
@delegate.verbose!(value)
|
||||
end
|
||||
|
||||
def call(...)
|
||||
@delegate.call(...)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,117 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2020-2024, by Samuel Williams.
|
||||
# Copyright, 2022, by Anton Sozontov.
|
||||
|
||||
require_relative "clock"
|
||||
|
||||
module Console
|
||||
class Progress
|
||||
def self.now
|
||||
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
|
||||
def initialize(subject, total = 0, minimum_output_duration: 0.1, **options)
|
||||
@subject = subject
|
||||
@options = options
|
||||
|
||||
@start_time = Progress.now
|
||||
|
||||
@last_output_time = nil
|
||||
@minimum_output_duration = minimum_output_duration
|
||||
|
||||
@current = 0
|
||||
@total = total
|
||||
end
|
||||
|
||||
attr :subject
|
||||
attr :current
|
||||
attr :total
|
||||
|
||||
def duration
|
||||
Progress.now - @start_time
|
||||
end
|
||||
|
||||
def ratio
|
||||
Rational(@current.to_f, @total.to_f)
|
||||
end
|
||||
|
||||
def remaining
|
||||
@total - @current
|
||||
end
|
||||
|
||||
def average_duration
|
||||
if @current > 0
|
||||
duration / @current
|
||||
end
|
||||
end
|
||||
|
||||
def estimated_remaining_time
|
||||
if average_duration = self.average_duration
|
||||
average_duration * remaining
|
||||
end
|
||||
end
|
||||
|
||||
def to_hash
|
||||
Hash.new.tap do |hash|
|
||||
hash[:type] = :progress
|
||||
hash[:current] = @current
|
||||
hash[:total] = @total
|
||||
|
||||
hash[:duration] = self.duration
|
||||
hash[:estimated_remaining_time] = self.estimated_remaining_time
|
||||
end
|
||||
end
|
||||
|
||||
def increment(amount = 1)
|
||||
@current += amount
|
||||
|
||||
if output?
|
||||
Console.call(@subject, self.to_s, event: self.to_hash, **@options)
|
||||
@last_output_time = Progress.now
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
def resize(total)
|
||||
@total = total
|
||||
|
||||
Console.call(@subject, self.to_s, event: self.to_hash, **@options)
|
||||
@last_output_time = Progress.now
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
def mark(*arguments, **options)
|
||||
Console.call(@subject, *arguments, **options, **@options)
|
||||
end
|
||||
|
||||
def to_s
|
||||
if estimated_remaining_time = self.estimated_remaining_time
|
||||
"#{@current}/#{@total} completed in #{Clock.formatted_duration(self.duration)}, #{Clock.formatted_duration(estimated_remaining_time)} remaining."
|
||||
else
|
||||
"#{@current}/#{@total} completed, waiting for estimate..."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def duration_since_last_output
|
||||
if @last_output_time
|
||||
Progress.now - @last_output_time
|
||||
end
|
||||
end
|
||||
|
||||
def output?
|
||||
if remaining.zero?
|
||||
return true
|
||||
elsif duration = duration_since_last_output
|
||||
return duration > @minimum_output_duration
|
||||
else
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
# Copyright, 2021, by Robert Schulze.
|
||||
|
||||
require_relative "filter"
|
||||
|
||||
module Console
|
||||
class Resolver
|
||||
# You can change the log level for different classes using CONSOLE_$LEVEL env vars.
|
||||
#
|
||||
# e.g. `CONSOLE_WARN=Acorn,Banana CONSOLE_DEBUG=Cat` will set the log level for the classes Acorn and Banana to `warn` and Cat to `debug`. This overrides the default log level.
|
||||
#
|
||||
# You can enable all log levels for a given class by using `CONSOLE_ON=MyClass`. Similarly you can disable all logging using `CONSOLE_OFF=MyClass`.
|
||||
#
|
||||
# @parameter logger [Logger] A logger instance to set the logging levels on.
|
||||
# @parameter env [Hash] The environment to read levels from.
|
||||
#
|
||||
# @returns [Nil] If there were no custom logging levels specified in the environment.
|
||||
# @returns [Resolver] If there were custom logging levels, then the created resolver is returned.
|
||||
def self.default_resolver(logger, env = ENV)
|
||||
# Find all CONSOLE_$LEVEL variables from environment:
|
||||
levels = logger.class::LEVELS
|
||||
.map{|label, level| [level, env["CONSOLE_#{label.upcase}"]&.split(",")]}
|
||||
.to_h
|
||||
.compact
|
||||
|
||||
off_klasses = env["CONSOLE_OFF"]&.split(",")
|
||||
on_klasses = env["CONSOLE_ON"]&.split(",")
|
||||
|
||||
resolver = nil
|
||||
|
||||
# If we have any levels, then create a class resolver, and each time a class is resolved, set the log level for that class to the specified level:
|
||||
if on_klasses&.any?
|
||||
resolver ||= Resolver.new
|
||||
|
||||
resolver.bind(on_klasses) do |klass|
|
||||
logger.enable(klass, logger.class::MINIMUM_LEVEL - 1)
|
||||
end
|
||||
end
|
||||
|
||||
if off_klasses&.any?
|
||||
resolver ||= Resolver.new
|
||||
|
||||
resolver.bind(off_klasses) do |klass|
|
||||
logger.disable(klass)
|
||||
end
|
||||
end
|
||||
|
||||
levels.each do |level, names|
|
||||
resolver ||= Resolver.new
|
||||
|
||||
resolver.bind(names) do |klass|
|
||||
logger.enable(klass, level)
|
||||
end
|
||||
end
|
||||
|
||||
return resolver
|
||||
end
|
||||
|
||||
def initialize
|
||||
@names = {}
|
||||
|
||||
@trace_point = TracePoint.new(:class, &self.method(:resolve))
|
||||
end
|
||||
|
||||
def bind(names, &block)
|
||||
names.each do |name|
|
||||
if klass = Object.const_get(name) rescue nil
|
||||
yield klass
|
||||
else
|
||||
@names[name] = block
|
||||
end
|
||||
end
|
||||
|
||||
if @names.any?
|
||||
@trace_point.enable
|
||||
else
|
||||
@trace_point.disable
|
||||
end
|
||||
end
|
||||
|
||||
def waiting?
|
||||
@trace_point.enabled?
|
||||
end
|
||||
|
||||
def resolve(trace_point)
|
||||
if block = @names.delete(trace_point.self.to_s)
|
||||
block.call(trace_point.self)
|
||||
end
|
||||
|
||||
if @names.empty?
|
||||
@trace_point.disable
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
require_relative "terminal/text"
|
||||
require_relative "terminal/xterm"
|
||||
|
||||
require_relative "terminal/formatter/progress"
|
||||
require_relative "terminal/formatter/failure"
|
||||
require_relative "terminal/formatter/spawn"
|
||||
|
||||
module Console
|
||||
module Terminal
|
||||
def self.for(io)
|
||||
if io.tty?
|
||||
XTerm.new(io)
|
||||
else
|
||||
Text.new(io)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Terminal
|
||||
module Formatter
|
||||
class Failure
|
||||
KEY = :failure
|
||||
|
||||
def initialize(terminal)
|
||||
@terminal = terminal
|
||||
|
||||
@terminal[:exception_title] ||= @terminal.style(:red, nil, :bold)
|
||||
@terminal[:exception_detail] ||= @terminal.style(:yellow)
|
||||
@terminal[:exception_backtrace] ||= @terminal.style(:red)
|
||||
@terminal[:exception_backtrace_other] ||= @terminal.style(:red, nil, :faint)
|
||||
@terminal[:exception_message] ||= @terminal.style(:default)
|
||||
end
|
||||
|
||||
def format(event, output, prefix: nil, verbose: false, width: 80)
|
||||
title = event[:class]
|
||||
message = event[:message]
|
||||
backtrace = event[:backtrace]
|
||||
root = event[:root]
|
||||
|
||||
lines = message.lines.map(&:chomp)
|
||||
|
||||
output.puts " #{prefix}#{@terminal[:exception_title]}#{title}#{@terminal.reset}: #{lines.shift}"
|
||||
|
||||
lines.each do |line|
|
||||
output.puts " #{@terminal[:exception_detail]}#{line}#{@terminal.reset}"
|
||||
end
|
||||
|
||||
root_pattern = /^#{root}\// if root
|
||||
|
||||
backtrace&.each_with_index do |line, index|
|
||||
path, offset, message = line.split(":", 3)
|
||||
style = :exception_backtrace
|
||||
|
||||
# Make the path a bit more readable:
|
||||
if root_pattern and path.sub!(root_pattern, "").nil?
|
||||
style = :exception_backtrace_other
|
||||
end
|
||||
|
||||
output.puts " #{index == 0 ? "→" : " "} #{@terminal[style]}#{path}:#{offset}#{@terminal[:exception_message]} #{message}#{@terminal.reset}"
|
||||
end
|
||||
|
||||
if cause = event[:cause]
|
||||
format(cause, output, prefix: "Caused by ", verbose: verbose, width: width)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2020-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Terminal
|
||||
module Formatter
|
||||
class Progress
|
||||
KEY = :progress
|
||||
|
||||
BLOCK = [
|
||||
" ",
|
||||
"▏",
|
||||
"▎",
|
||||
"▍",
|
||||
"▌",
|
||||
"▋",
|
||||
"▊",
|
||||
"▉",
|
||||
"█",
|
||||
]
|
||||
|
||||
def initialize(terminal)
|
||||
@terminal = terminal
|
||||
@terminal[:progress_bar] ||= terminal.style(:blue, :white)
|
||||
end
|
||||
|
||||
def format(event, output, verbose: false, width: 80)
|
||||
current = event[:current].to_f
|
||||
total = event[:total].to_f
|
||||
value = current / total
|
||||
|
||||
# Clamp value to 1.0 to avoid rendering issues:
|
||||
if value > 1.0
|
||||
value = 1.0
|
||||
end
|
||||
|
||||
output.puts "#{@terminal[:progress_bar]}#{self.bar(value, width-10)}#{@terminal.reset} #{sprintf('%6.2f', value * 100)}%"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bar(value, width)
|
||||
blocks = width * value
|
||||
full_blocks = blocks.floor
|
||||
partial_block = ((blocks - full_blocks) * BLOCK.size).floor
|
||||
|
||||
if partial_block.zero?
|
||||
BLOCK.last * full_blocks
|
||||
else
|
||||
"#{BLOCK.last * full_blocks}#{BLOCK[partial_block]}"
|
||||
end.ljust(width)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Terminal
|
||||
module Formatter
|
||||
# Format spawn events.
|
||||
class Spawn
|
||||
KEY = :spawn
|
||||
|
||||
def initialize(terminal)
|
||||
@terminal = terminal
|
||||
@terminal[:spawn_command] ||= @terminal.style(:blue, nil, :bold)
|
||||
end
|
||||
|
||||
def format(event, output, verbose: false, width: 80)
|
||||
environment, arguments, options = event.values_at(:environment, :arguments, :options)
|
||||
|
||||
arguments = arguments.flatten.collect(&:to_s)
|
||||
|
||||
output.puts "#{@terminal[:spawn_command]}#{arguments.join(' ')}#{@terminal.reset}#{chdir_string(options)}"
|
||||
|
||||
if verbose and environment
|
||||
environment.each do |key, value|
|
||||
output.puts "export #{key}=#{value}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def chdir_string(options)
|
||||
if options and chdir = options[:chdir]
|
||||
" in #{chdir}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
require "io/console"
|
||||
|
||||
module Console
|
||||
# Styled terminal output.
|
||||
module Terminal
|
||||
class Text
|
||||
def initialize(output)
|
||||
@output = output
|
||||
@styles = {reset: self.reset}
|
||||
end
|
||||
|
||||
def [] key
|
||||
@styles[key]
|
||||
end
|
||||
|
||||
def []= key, value
|
||||
@styles[key] = value
|
||||
end
|
||||
|
||||
def colors?
|
||||
false
|
||||
end
|
||||
|
||||
def width
|
||||
80
|
||||
end
|
||||
|
||||
def style(foreground, background = nil, *attributes)
|
||||
end
|
||||
|
||||
def reset
|
||||
end
|
||||
|
||||
def write(*arguments, style: nil)
|
||||
if style and prefix = self[style]
|
||||
@output.write(prefix)
|
||||
@output.write(*arguments)
|
||||
@output.write(self.reset)
|
||||
else
|
||||
@output.write(*arguments)
|
||||
end
|
||||
end
|
||||
|
||||
def puts(*arguments, style: nil)
|
||||
if style and prefix = self[style]
|
||||
@output.write(prefix)
|
||||
@output.puts(*arguments)
|
||||
@output.write(self.reset)
|
||||
else
|
||||
@output.puts(*arguments)
|
||||
end
|
||||
end
|
||||
|
||||
# Print out the given arguments.
|
||||
# When the argument is a symbol, look up the style and inject it into the output stream.
|
||||
# When the argument is a proc/lambda, call it with self as the argument.
|
||||
# When the argument is anything else, write it directly to the output.
|
||||
def print(*arguments)
|
||||
arguments.each do |argument|
|
||||
case argument
|
||||
when Symbol
|
||||
@output.write(self[argument])
|
||||
when Proc
|
||||
argument.call(self)
|
||||
else
|
||||
@output.write(argument)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Print out the arguments as per {#print}, followed by the reset sequence and a newline.
|
||||
def print_line(*arguments)
|
||||
print(*arguments)
|
||||
@output.puts(self.reset)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
require "io/console"
|
||||
|
||||
require_relative "text"
|
||||
|
||||
module Console
|
||||
# Styled terminal output.
|
||||
module Terminal
|
||||
class XTerm < Text
|
||||
COLORS = {
|
||||
black: 0,
|
||||
red: 1,
|
||||
green: 2,
|
||||
yellow: 3,
|
||||
blue: 4,
|
||||
magenta: 5,
|
||||
cyan: 6,
|
||||
white: 7,
|
||||
default: 9,
|
||||
}.freeze
|
||||
|
||||
ATTRIBUTES = {
|
||||
normal: 0,
|
||||
bold: 1,
|
||||
bright: 1,
|
||||
faint: 2,
|
||||
italic: 3,
|
||||
underline: 4,
|
||||
blink: 5,
|
||||
reverse: 7,
|
||||
hidden: 8,
|
||||
}.freeze
|
||||
|
||||
def colors?
|
||||
true
|
||||
end
|
||||
|
||||
def size
|
||||
@output.winsize
|
||||
rescue Errno::ENOTTY
|
||||
# Fake it...
|
||||
[24, 80]
|
||||
end
|
||||
|
||||
def width
|
||||
size.last
|
||||
end
|
||||
|
||||
def style(foreground, background = nil, *attributes)
|
||||
tokens = []
|
||||
|
||||
if foreground
|
||||
tokens << 30 + COLORS.fetch(foreground)
|
||||
end
|
||||
|
||||
if background
|
||||
tokens << 40 + COLORS.fetch(background)
|
||||
end
|
||||
|
||||
attributes.each do |attribute|
|
||||
tokens << ATTRIBUTES.fetch(attribute){attribute.to_i}
|
||||
end
|
||||
|
||||
return "\e[#{tokens.join(';')}m"
|
||||
end
|
||||
|
||||
def reset
|
||||
"\e[0m"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
VERSION = "1.29.3"
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2024, by Samuel Williams.
|
||||
|
||||
require_relative "logger"
|
||||
|
||||
module Console
|
||||
# Whether the current fiber is emitting a warning.
|
||||
Fiber.attr_accessor :console_warn
|
||||
|
||||
# Redirect warnings to Console.warn.
|
||||
module Warn
|
||||
# Redirect warnings to {Console.warn}.
|
||||
def warn(message, **options)
|
||||
fiber = Fiber.current
|
||||
|
||||
# We do this to be extra pendantic about avoiding infinite recursion.
|
||||
return super if fiber.console_warn
|
||||
|
||||
begin
|
||||
fiber.console_warn = true
|
||||
message.chomp!
|
||||
|
||||
Console::Logger.instance.warn(message, **options)
|
||||
ensure
|
||||
fiber.console_warn = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
::Warning.extend(Warn)
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
# MIT License
|
||||
|
||||
Copyright, 2019-2025, by Samuel Williams.
|
||||
Copyright, 2019-2021, by Bryan Powell.
|
||||
Copyright, 2019, by Cyril Roelandt.
|
||||
Copyright, 2020, by Olle Jonsson.
|
||||
Copyright, 2020, by Michael Adams.
|
||||
Copyright, 2021, by Cédric Boutillier.
|
||||
Copyright, 2021, by Robert Schulze.
|
||||
Copyright, 2022, by Anton Sozontov.
|
||||
Copyright, 2022, by William T. Nelson.
|
||||
Copyright, 2023, by Felix Yan.
|
||||
Copyright, 2024, by Patrik Wenger.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Console
|
||||
|
||||
Provides beautiful console logging for Ruby applications. Implements fast, buffered log output.
|
||||
|
||||
[](https://github.com/socketry/console/actions?workflow=Test)
|
||||
|
||||
## Motivation
|
||||
|
||||
When Ruby decided to reverse the order of exception backtraces, I finally gave up using the built in logging and decided restore sanity to the output of my programs once and for all\!
|
||||
|
||||
## Features
|
||||
|
||||
- Thread safe global logger with per-fiber context.
|
||||
- Carry along context with nested loggers.
|
||||
- Enable/disable log levels per-class.
|
||||
- Detailed logging of exceptions.
|
||||
- Beautiful logging to the terminal or structured logging using JSON.
|
||||
|
||||
## Usage
|
||||
|
||||
Please see the [project documentation](https://socketry.github.io/console/) for more details.
|
||||
|
||||
- [Getting Started](https://socketry.github.io/console/guides/getting-started/index) - This guide explains how to use `console` for logging.
|
||||
|
||||
- [Command Line](https://socketry.github.io/console/guides/command-line/index) - This guide explains how the `console` gem can be controlled using environment variables.
|
||||
|
||||
- [Integration](https://socketry.github.io/console/guides/integration/index) - This guide explains how to integrate the `console` output into different systems.
|
||||
|
||||
- [Events](https://socketry.github.io/console/guides/events/index) - This guide explains how to log structured events with a well-defined schema.
|
||||
|
||||
## Releases
|
||||
|
||||
Please see the [project releases](https://socketry.github.io/console/releases/index) for all releases.
|
||||
|
||||
### v1.29.3
|
||||
|
||||
- Serialized output now uses `IO#write` with a single string to reduce the chance of interleaved output.
|
||||
|
||||
### v1.29.2
|
||||
|
||||
- Always return `nil` from `Console::Filter` logging methods.
|
||||
|
||||
### v1.29.1
|
||||
|
||||
- Fix logging `exception:` keyword argument when the value was not an exception.
|
||||
|
||||
### v1.29.0
|
||||
|
||||
- Don't make `Kernel#warn` redirection to `Console.warn` the default behavior, you must `require 'console/warn'` to enable it.
|
||||
- Remove deprecated `Console::Logger#failure`.
|
||||
- [Consistent Handling of Exceptions](https://socketry.github.io/console/releases/index#consistent-handling-of-exceptions)
|
||||
|
||||
### v1.28.0
|
||||
|
||||
- Add support for `Kernel#warn` redirection to `Console.warn`.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions to this project.
|
||||
|
||||
1. Fork it.
|
||||
2. Create your feature branch (`git checkout -b my-new-feature`).
|
||||
3. Commit your changes (`git commit -am 'Add some feature'`).
|
||||
4. Push to the branch (`git push origin my-new-feature`).
|
||||
5. Create new Pull Request.
|
||||
|
||||
### Developer Certificate of Origin
|
||||
|
||||
In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
|
||||
|
||||
### Community Guidelines
|
||||
|
||||
This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
|
||||
|
||||
## See Also
|
||||
|
||||
- [console-adapter-rails](https://github.com/socketry/console-adapter-rails)
|
||||
- [console-adapter-sidekiq](https://github.com/socketry/console-adapter-sidekiq)
|
||||
- [console-output-datadog](https://github.com/socketry/console-output-datadog)
|
||||
- [sus-fixtures-console](https://github.com/sus-rb/sus-fixtures-console)
|
||||
@@ -0,0 +1,37 @@
|
||||
# Releases
|
||||
|
||||
## v1.29.3
|
||||
|
||||
- Serialized output now uses `IO#write` with a single string to reduce the chance of interleaved output.
|
||||
|
||||
## v1.29.2
|
||||
|
||||
- Always return `nil` from `Console::Filter` logging methods.
|
||||
|
||||
## v1.29.1
|
||||
|
||||
- Fix logging `exception:` keyword argument when the value was not an exception.
|
||||
|
||||
## v1.29.0
|
||||
|
||||
- Don't make `Kernel#warn` redirection to `Console.warn` the default behavior, you must `require 'console/warn'` to enable it.
|
||||
- Remove deprecated `Console::Logger#failure`.
|
||||
|
||||
### Consistent Handling of Exceptions
|
||||
|
||||
`Console.call` and all wrapper methods will now consistently handle exceptions that are the last positional argument or keyword argument. This means that the following code will work as expected:
|
||||
|
||||
``` ruby
|
||||
begin
|
||||
rescue => error
|
||||
# Last positional argument:
|
||||
Console.warn(self, "There may be an issue", error)
|
||||
|
||||
# Keyword argument (preferable):
|
||||
Console.error(self, "There is an issue", exception: error)
|
||||
end
|
||||
```
|
||||
|
||||
## v1.28.0
|
||||
|
||||
- Add support for `Kernel#warn` redirection to `Console.warn`.
|
||||
Reference in New Issue
Block a user