From d157e531295ee7189a0d4e8e09101497739a62cd Mon Sep 17 00:00:00 2001 From: Holger Just Date: Tue, 3 Oct 2017 20:25:30 +0200 Subject: [PATCH] Add logger adapter to write log events to an external logger --- lib/rackstash.rb | 1 + lib/rackstash/adapters/logger.rb | 118 +++++++++++++++++++++++++ spec/rackstash/adapters/logger_spec.rb | 118 +++++++++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 lib/rackstash/adapters/logger.rb create mode 100644 spec/rackstash/adapters/logger_spec.rb diff --git a/lib/rackstash.rb b/lib/rackstash.rb index 11e28b0..4edc9ca 100644 --- a/lib/rackstash.rb +++ b/lib/rackstash.rb @@ -141,5 +141,6 @@ require 'rackstash/logger' require 'rackstash/adapters/callable' require 'rackstash/adapters/file' +require 'rackstash/adapters/logger' require 'rackstash/adapters/io' require 'rackstash/adapters/null' diff --git a/lib/rackstash/adapters/logger.rb b/lib/rackstash/adapters/logger.rb new file mode 100644 index 0000000..4cc7104 --- /dev/null +++ b/lib/rackstash/adapters/logger.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true +# +# Copyright 2017 Holger Just +# +# This software may be modified and distributed under the terms +# of the MIT license. See the LICENSE.txt file for details. + +require 'logger' + +require 'rackstash/adapters/adapter' + +module Rackstash + module Adapters + # The Logger adapter can be used to write formatted logs to an existing + # logger. This is especially useful with libraries exposing a + # logger-compatible interface for an external protocol. Example of such + # loggers include Ruby's `Syslog::Logger` class, a `Loglier` logger to log + # to Loggly or a fluentd logger. + # + # The only expectation to the passed logger instance is that it responds to + # the `add` method with the same semantics as the `Logger` class in Ruby's + # standard library. All logs emitted to the given logger will be emitted + # with the defined severity (`INFO` by default). Since a log event in + # Rackstash can contain multiple concatenanted messages, you should make + # sure to format them properly with {Filters} or a custom encoder if + # required. + # + # While most loggers expect Strings as arguments to their `add` method, some + # also work with hashes or similar data structures. Make sure to configure a + # suitable `encoder` in the responsible {Flow}. By default, we use a JSON + # encoder. + # + # @note When logging to a local file or to an IO object (like `STDOUT` or + # `STDERR`), you should use the {File} encoder respectively the {IO} encoder + # instead which usally provide stronger consistency guarantees and are + # faster. + class Logger < Adapter + register_for ::Logger, 'Syslog::Logger' + + # @param logger [#add] A base logger to send log lines to. We only expect + # this object to implement an `add` method which behaves similar to the + # one of the Ruby standard library `Logger` class. + # @param severity [Integer, String, Symbol] the severity of the logs + # emitted to the base `logger`. It can be specified as either one of the + # {SEVERITIES} or a `String` or `Symbol` describing the severity. + def initialize(logger, severity: INFO) + if logger.respond_to?(:add) + @logger = logger + else + raise TypeError, "#{logger.inspect} does not look like a logger" + end + + self.severity = severity + end + + # @return [Integer] the severity which will be used to add log events to + # the base logger. + def severity + @severity + end + + # This attribute sets the severity of the logs emitted to the base logger. + # It can be specified as either one of the {SEVERITIES} or a `String` or + # `Symbol` describing the severity (i.e. its name). + # + # @param severity [Integer, String, Symbol] the severity of the logs + # emitted to the base `logger`. It can be specified as either one of the + # {SEVERITIES} or a `String` or `Symbol` describing the severity. + # @raise [ArgumentError] if no severity could be found for the given + # value. + def severity=(severity) + if severity.is_a?(Integer) + @severity = severity + else + @severity = SEVERITY_NAMES.fetch(severity.to_s.downcase) do + raise ArgumentError, "invalid log severity: #{severity.inspect}" + end + end + end + + # Close the base logger (if supported). The exact behavior is dependent on + # the given logger. + # + # Usually, no further writes are possible after closing. Further attempts + # to {#write} will usually result in an exception being thrown. + # + # @return [nil] + def close + @logger.close if @logger.respond_to?(:close) + nil + end + + # Reopen the base logger (if supported). The exact behavior is dependent + # on the given logger. + # + # @return [nil] + def reopen + @logger.reopen if @logger.respond_to?(:reopen) + nil + end + + # Emit a single log line to the base logger with the configured log + # {#severity}. If the `Encoder` of the responsible {Flow} created a + # `String` object, we will log it to the logger with a trailing newline + # removed. Other objects like a `Hash` are passed along unchanged. + # + # @param log [#to_s] the encoded log event. Most loggers expect a `String` + # here. Be sure to use a compatible encoder in the responsible {Flow}. + # @return [nil] + def write_single(log) + log = log.chomp("\n".freeze) if log.is_a?(String) + + @logger.add(@severity, log) + nil + end + end + end +end diff --git a/spec/rackstash/adapters/logger_spec.rb b/spec/rackstash/adapters/logger_spec.rb new file mode 100644 index 0000000..e34ae84 --- /dev/null +++ b/spec/rackstash/adapters/logger_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true +# +# Copyright 2017 Holger Just +# +# This software may be modified and distributed under the terms +# of the MIT license. See the LICENSE.txt file for details. + +require 'spec_helper' +require 'stringio' + +require 'rackstash/adapters/logger' + +describe Rackstash::Adapters::Logger do + let(:bucket) { + Struct.new(:lines) do + def write(log) + raise IOError if @closed + lines << log + end + + def close + @closed = true + end + + def closed? + @closed + end + end.new([]) + } + let(:logger) { + ::Logger.new(bucket).tap do |logger| + logger.formatter = ->(_severity, _time, _progname, msg) { msg } + + # mock the reopen method on this logger + def logger.reopen + end + end + } + let(:logger_ducky) { + Object.new.tap do |duck| + allow(duck).to receive(:add) + end + } + + let(:adapter) { described_class.new(logger) } + + describe '#initialize' do + it 'accepts a Logger object' do + expect { described_class.new(logger) }.not_to raise_error + expect { described_class.new(logger_ducky) }.not_to raise_error + end + + it 'rejects non-logger objects' do + expect { described_class.new(nil) }.to raise_error TypeError + expect { described_class.new('hello') }.to raise_error TypeError + expect { described_class.new(Object.new) }.to raise_error TypeError + end + end + + describe '.default_encoder' do + it 'returns a JSON encoder' do + expect(adapter.default_encoder).to be_instance_of Rackstash::Encoders::JSON + end + end + + describe '#close' do + context 'with logger' do + it 'closes the logger object' do + expect(bucket).not_to be_closed + expect(logger).to receive(:close).and_call_original + adapter.close + expect(bucket).to be_closed + end + end + + context 'with logger_ducky' do + let(:logger) { logger_ducky } + + it 'ignores the call if unsupported' do + expect { adapter.close }.not_to raise_error + end + end + end + + describe '#reopen' do + context 'with logger' do + it 'closes the logger object' do + expect(logger).to receive(:reopen).and_call_original + adapter.reopen + end + end + + context 'with logger_ducky' do + let(:logger) { logger_ducky } + + it 'ignores the call if unsupported' do + expect { adapter.reopen }.not_to raise_error + end + end + end + + describe '#write_single' do + it 'writes the log line to the logger object' do + adapter.write('a log line') + expect(bucket.lines.last).to eql 'a log line' + end + + it 'passes the raw object to the logger' do + adapter.write([123, 'hello']) + expect(bucket.lines.last).to eql [123, 'hello'] + end + + it 'removes a trailing newline if present' do + adapter.write("a full line.\n") + expect(bucket.lines.last).to eql 'a full line.' + end + end +end