# 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 = {}) unless environment =~ /default|rspec/ warn "No configuration for environment #{environment}. Using 'default'" end 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 # TODO: Default to SAPIENCE_ENV (if present it should be returned first) 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 warn "Sapience is going to use default configuration" 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] 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