lib/sapience/sapience.rb
# frozen_string_literal: true
require "concurrent"
require "socket"
require "sapience/descendants"
require "English"
# Example:
#
# Sapience.configure do |config|
# config.default_level = ENV.fetch('SAPIENCE_DEFAULT_LEVEL') { :info }.to_sym
# config.backtrace_level = ENV.fetch('SAPIENCE_BACKTRACE_LEVEL') { :info }.to_sym
# config.app_name = 'TestApplication'
# config.host = ENV.fetch('SAPIENCE_HOST', nil)
# config.ap_options = { multiline: false }
# config.filter_parameters << "credit_card"
# config.appenders = [
# { stream: { io: STDOUT, formatter: :color } },
# { statsd: { url: 'udp://localhost:2222' } },
# { sentry: { dsn: 'https://foobar:443' } },
# ]
# end
# rubocop:disable ClassVars
module Sapience
UnknownClass = Class.new(NameError)
AppNameMissing = Class.new(NameError)
TestException = Class.new(StandardError)
UnkownLogLevel = Class.new(StandardError)
InvalidLogExecutor = Class.new(StandardError)
MissingConfiguration = Class.new(StandardError)
@@configured = false
# Logging levels in order of most detailed to most severe
APP_NAME = "APP_NAME"
DEFAULT_ENV = "default"
RACK_ENV = "RACK_ENV"
RAILS_ENV = "RAILS_ENV"
SAPIENCE_ENV = "SAPIENCE_ENV"
LEVELS = %i[trace debug info warn error fatal].freeze
APPENDER_NAMESPACE = Sapience::Appender
METRICS_NAMESPACE = Sapience::Metrics
ERROR_HANDLER_NAMESPACE = Sapience::ErrorHandler
DEFAULT_STATSD_URL = "udp://localhost:8125"
def self.configure(force: false)
yield config if block_given?
return config if configured? && force == false
reset_appenders!
add_appenders(*config.appenders)
@@configured = true
config
end
def self.config_hash
@@config_hash ||= ConfigLoader.load_from_file
end
def self.config
@@config ||= begin
options = config_hash[environment]
options ||= default_options(config_hash)
Configuration.new(options)
end
end
def self.configured?
@@configured
end
def self.default_options(options = {})
warn "No configuration for environment #{environment}. Using 'default'" unless environment =~ /default|rspec/
options[DEFAULT_ENV]
end
def self.reset!
@@config = nil
@@logger = nil
@@metrics = nil
@@error_handler = nil
@@environment = nil
@@configured = false
@@config_hash = nil
clear_tags!
reset_appenders!
end
def self.reset_appenders!
@@appenders = Concurrent::Array.new
end
def self.environment
@@environment ||=
ENV.fetch(SAPIENCE_ENV) do
ENV.fetch(RAILS_ENV) do
ENV.fetch(RACK_ENV) do
if defined?(::Rails) && ::Rails.respond_to?(:env)
::Rails.env
else
DEFAULT_ENV
end
end
end
end
end
def self.app_name
config.app_name ||= app_name_builder
fail AppNameMissing, "app_name is not configured. See documentation for more information" unless config.app_name
config.app_name
end
def self.namify(appname, sep = "_")
return unless appname.is_a?(String)
return if appname.empty?
# Turn unwanted chars into the separator
appname = appname.dup
appname.gsub!(/[^a-z0-9\-_]+/i, sep)
unless sep.nil? || sep.empty?
re_sep = Regexp.escape(sep)
# No more than one of the separator in a row.
appname.gsub!(/#{re_sep}{2,}/, sep)
# Remove leading/trailing separator.
appname.gsub!(/^#{re_sep}|#{re_sep}$/, "")
end
appname.downcase
end
# Return a logger for the supplied class or class_name
def self.[](klass)
Sapience::Logger.new(klass)
end
# Add a new logging appender as a new destination for all log messages
# emitted from Sapience
#
# Appenders will be written to in the order that they are added
#
# If a block is supplied then it will be used to customize the format
# of the messages sent to that appender. See Sapience::Logger.new for
# more information on custom formatters
#
# Parameters
# file_name: [String]
# File name to write log messages to.
#
# Or,
# io: [IO]
# An IO Stream to log to.
# For example STDOUT, STDERR, etc.
#
# Or,
# appender: [Symbol|Sapience::Subscriber]
# A symbol identifying the appender to create.
# For example:
# :bugsnag, :elasticsearch, :graylog, :http, :mongodb, :new_relic, :splunk_http, :syslog, :wrapper
# Or,
# An instance of an appender derived from Sapience::Subscriber
# For example:
# Sapience::Appender::Http.new(url: 'http://localhost:8088/path')
#
# Or,
# logger: [Logger|Log4r]
# An instance of a Logger or a Log4r logger.
#
# level: [:trace | :debug | :info | :warn | :error | :fatal]
# Override the log level for this appender.
# Default: Sapience.config.default_level
#
# formatter: [Symbol|Object|Proc]
# Any of the following symbol values: :default, :color, :json
# Or,
# An instance of a class that implements #call
# Or,
# A Proc to be used to format the output from this appender
# Default: :default
#
# 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.
#
# Examples:
#
# # Send all logging output to Standard Out (Screen)
# Sapience.add_appender(:stream, io: STDOUT)
#
# # Send all logging output to a file
# Sapience.add_appender(:stream, file_name: 'logfile.log')
#
# # Send all logging output to a file and only :info and above to standard output
# Sapience.add_appender(:stream, file_name: 'logfile.log')
# Sapience.add_appender(:stream, io: STDOUT, level: :info)
#
# Log to log4r, Logger, etc.:
#
# # Send logging output to an existing logger
# require 'logger'
# require 'sapience'
#
# # Built-in Ruby logger
# log = Logger.new(STDOUT)
# log.level = Logger::DEBUG
#
# Sapience.config.default_level = :debug
# Sapience.add_appender(:wrapper, logger: log)
#
# logger = Sapience['Example']
# logger.info "Hello World"
# logger.debug("Login time", user: 'Joe', duration: 100, ip_address: '127.0.0.1')
def self.add_appender(appender_class_name, options = {}, _deprecated_level = nil, &_block)
fail ArgumentError, "options should be a hash" unless options.is_a?(Hash)
options = options.dup.deep_symbolize_keyz!
appender_class = constantize_symbol(appender_class_name)
validate_appender_class!(appender_class)
appender = appender_class.new(options)
warn "appender #{appender} with (#{options.inspect}) is not valid" unless appender.valid?
@@appenders << appender
# Start appender thread if it is not already running
Sapience::Logger.start_appender_thread
Sapience::Logger.start_invalid_appenders_task
appender
end
def self.validate_appender_class!(appender_class)
return if known_appenders.include?(appender_class)
fail NotImplementedError,
"Unknown appender '#{appender_class}'. Supported appenders are (#{known_appenders.join(", ")})"
end
def self.known_appenders
@known_appenders ||= Sapience::Subscriber.descendants
end
# Examples:
# Sapience.add_appenders(
# { file: { io: STDOUT } },
# { sentry: { dsn: "https://app.getsentry.com/" } },
# )
def self.add_appenders(*appenders)
appenders.flatten.compact.each do |appender|
appender.each do |name, options|
add_appender(name, options)
end
end
end
# Remove an existing appender
# Currently only supports appender instances
# TODO: Make it possible to remove appenders by type
# Maybe create a concurrent collection that allows this by inheriting from concurrent array.
def self.remove_appender(appender)
@@appenders.delete(appender)
end
# Remove specific appenders or all existing
def self.remove_appenders(appenders = @@appenders)
appenders.each do |appender|
remove_appender(appender)
end
end
# Returns [Sapience::Subscriber] a copy of the list of active
# appenders for debugging etc.
# Use Sapience.add_appender and Sapience.remove_appender
# to manipulate the active appenders list
def self.appenders
@@appenders.clone
end
def self.metrics=(metrics)
@@metrics = metrics
end
def self.metrics
@@metrics ||= create_class(config.metrics, METRICS_NAMESPACE)
end
def self.error_handler=(error_handler)
@@error_handler = error_handler
end
def self.error_handler
@@error_handler ||= create_class(config.error_handler, ERROR_HANDLER_NAMESPACE)
end
def self.capture_exception(exception, payload = {})
error_handler.capture_exception(exception, payload)
end
def self.capture_message(message, payload = {})
error_handler.capture_message(message, payload)
end
def self.create_class(config_section, namespace)
namespace_string = namespace.to_s.split("::").last
fail MissingConfiguration, "No #{namespace_string} configured" unless config_section
klass_name = config_section.keys.first
options = config_section.values.first
klass = constantize_symbol(klass_name, namespace)
if namespace.descendants.include?(klass)
klass.new(options)
else
fail NotImplementedError, "Unknown #{namespace_string} '#{klass_name}'"
end
end
def self.logger=(logger)
@@logger = Sapience::Logger.logger = logger
end
def self.logger
@@logger ||= Sapience::Logger.logger
end
def self.test_exception(_level = :error)
fail Sapience::TestException, "Sapience Test Exception"
rescue Sapience::TestException => ex
Sapience.capture_exception(ex, test_exception: true)
end
# Wait until all queued log messages have been written and flush all active
# appenders
def self.flush
Sapience::Logger.flush
end
# Close and flush all appenders
def self.close
Sapience::Logger.close
end
# After forking an active process call Sapience.reopen to re-open
# any open file handles etc to resources
#
# Note: Only appenders that implement the reopen method will be called
def self.reopen
@@appenders.each { |appender| appender.reopen if appender.respond_to?(:reopen) }
# After a fork the appender thread is not running, start it if it is not running
Sapience::Logger.start_appender_thread
end
# If the tag being supplied is definitely a string then this fast
# tag api can be used for short lived tags
def self.fast_tag(tag)
(Thread.current[:sapience_tags] ||= []) << tag
yield
ensure
Thread.current[:sapience_tags].pop
end
# Add the supplied tags to the list of tags to log for this thread whilst
# the supplied block is active.
# Returns result of block
def self.tagged(*tags)
new_tags = push_tags(*tags)
yield self
ensure
pop_tags(new_tags.size)
end
# Returns a copy of the [Array] of [String] tags currently active for this thread
# Returns nil if no tags are set
def self.tags
# Since tags are stored on a per thread basis this list is thread-safe
t = Thread.current[:sapience_tags]
t.nil? ? [] : t.clone
end
# Add tags to the current scope
# Returns the list of tags pushed after flattening them out and removing blanks
def self.push_tags(*tags)
# Need to flatten and reject empties to support calls from Rails 4
new_tags = tags.flatten.collect(&:to_s).reject(&:empty?)
t = Thread.current[:sapience_tags]
Thread.current[:sapience_tags] = t.nil? ? new_tags : t.concat(new_tags)
new_tags
end
def self.clear_tags!
Thread.current[:sapience_tags] = []
end
# Remove specified number of tags from the current tag list
def self.pop_tags(quantity = 1)
t = Thread.current[:sapience_tags]
return if t.nil?
t.pop(quantity)
end
# Silence noisy log levels by changing the default_level within the block
#
# This setting is thread-safe and only applies to the current thread
#
# Any threads spawned within the block will not be affected by this setting
#
# #silence can be used to both raise and lower the log level within
# the supplied block.
#
# Example:
#
# # Perform trace level logging within the block when the default is higher
# Sapience.config.default_level = :info
#
# logger.debug 'this will _not_ be logged'
#
# Sapience.silence(:trace) do
# logger.debug "this will be logged"
# end
#
# Parameters
# new_level
# The new log level to apply within the block
# Default: :error
#
# Example:
# # Silence all logging for this thread below :error level
# Sapience.silence do
# logger.info "this will _not_ be logged"
# logger.warn "this neither"
# logger.error "but errors will be logged"
# end
#
# Note:
# #silence does not affect any loggers which have had their log level set
# explicitly. I.e. That do not rely on the global default level
def self.silence(new_level = :error)
current_index = Thread.current[:sapience_silence]
Thread.current[:sapience_silence] = Sapience.config.level_to_index(new_level)
yield
ensure
Thread.current[:sapience_silence] = current_index
end
reset_appenders!
def self.log_executor_class
constantize_symbol(config.log_executor, "Concurrent")
end
def self.constantize_symbol(symbol, namespace = APPENDER_NAMESPACE)
class_name = "#{namespace}::#{symbol.to_sym.camelize}"
constantize(class_name)
end
def self.constantize(class_name)
return class_name unless class_name.is_a?(String)
if RUBY_VERSION.to_i >= 2
Object.const_get(class_name)
else
class_name.split("::").inject(Object) { |o, name| o.const_get(name) }
end
rescue NameError
raise UnknownClass, "Could not find class: #{class_name}."
end
def self.root
@root ||= Gem::Specification.find_by_name("sapience").gem_dir
end
def self.app_name_builder
config_hash.fetch(environment) { {} }["app_name"] ||
config_hash.fetch(DEFAULT_ENV) { {} }["app_name"] ||
ENV[APP_NAME]
end
end
# rubocop:enable ClassVars