From 3081b03db18a1508b74aab8caf1c6288db6dca8f Mon Sep 17 00:00:00 2001 From: Holger Just Date: Wed, 18 Jan 2017 23:34:55 +0100 Subject: [PATCH] Add basic logger structure with early spikes The Rackstash::Logger class will server as the public main entry point for users. It will eventually implement the mostly complete interface of Ruby's Logger. The idea of Rackstash is the we will allow to buffer multiple log messages allong with additional data until a combined log event is eventually flushed to an underlying log target. This allows to keep connected log messages and data as a single unit from the start without having to painstakingly parse and connect these in later systems again. --- Gemfile | 3 +- exe/rackstash | 0 lib/rackstash.rb | 15 +- lib/rackstash/buffer.rb | 23 +++ lib/rackstash/buffer_stack.rb | 19 +++ lib/rackstash/formatter.rb | 28 ++++ lib/rackstash/logger.rb | 140 ++++++++++++++++++ lib/rackstash/message.rb | 89 +++++++++++ lib/rackstash/sink.rb | 20 +++ spec/rackstash/buffer_spec.rb | 38 +++++ spec/rackstash/buffer_stack_spec.rb | 20 +++ spec/rackstash/formatter_spec.rb | 48 ++++++ spec/rackstash/logger_spec.rb | 219 ++++++++++++++++++++++++++++ spec/rackstash/message_spec.rb | 159 ++++++++++++++++++++ spec/rackstash/sink_spec.rb | 37 +++++ spec/rackstash_spec.rb | 14 ++ spec/spec_helper.rb | 10 ++ 17 files changed, 879 insertions(+), 3 deletions(-) mode change 100644 => 100755 exe/rackstash create mode 100644 lib/rackstash/buffer.rb create mode 100644 lib/rackstash/buffer_stack.rb create mode 100644 lib/rackstash/formatter.rb create mode 100644 lib/rackstash/logger.rb create mode 100644 lib/rackstash/message.rb create mode 100644 lib/rackstash/sink.rb create mode 100644 spec/rackstash/buffer_spec.rb create mode 100644 spec/rackstash/buffer_stack_spec.rb create mode 100644 spec/rackstash/formatter_spec.rb create mode 100644 spec/rackstash/logger_spec.rb create mode 100644 spec/rackstash/message_spec.rb create mode 100644 spec/rackstash/sink_spec.rb diff --git a/Gemfile b/Gemfile index 80a5532..d06d8c5 100644 --- a/Gemfile +++ b/Gemfile @@ -5,5 +5,4 @@ source 'https://rubygems.org' -# Specify your gem's dependencies in rackstash.gemspec -gemspec +gemspec name: 'rackstash' diff --git a/exe/rackstash b/exe/rackstash old mode 100644 new mode 100755 diff --git a/lib/rackstash.rb b/lib/rackstash.rb index 70e2ae4..77274b9 100644 --- a/lib/rackstash.rb +++ b/lib/rackstash.rb @@ -6,5 +6,18 @@ require 'rackstash/version' module Rackstash - # Your code goes here... + SEVERITIES = [ + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, + FATAL = 4, + UNKNOWN = 5 + ].freeze + + PROGNAME = "rackstash/v#{Rackstash::VERSION}".freeze + + EMPTY_STRING = ''.freeze end + +require 'rackstash/logger' diff --git a/lib/rackstash/buffer.rb b/lib/rackstash/buffer.rb new file mode 100644 index 0000000..20cbcee --- /dev/null +++ b/lib/rackstash/buffer.rb @@ -0,0 +1,23 @@ +# 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. + +module Rackstash + # The Buffer holds all the data of a single log event. It can hold multiple + # messages of multiple calls to the log, additional fields holding structured + # data about the log event, and tags identiying the type of log. + class Buffer + def initialize + @messages = [] + end + + def add_message(message) + @messages << message + end + + def messages + @messages.dup + end + end +end diff --git a/lib/rackstash/buffer_stack.rb b/lib/rackstash/buffer_stack.rb new file mode 100644 index 0000000..c48a506 --- /dev/null +++ b/lib/rackstash/buffer_stack.rb @@ -0,0 +1,19 @@ +# 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 'rackstash/buffer' + +module Rackstash + # A BufferStack controls one or more Buffers. Each {Buffer} is created, + # referenced by, and accessed via exactly one BufferStack. Each BufferStack + # is used by exactly one BufferedLogger. The responsible {BufferedLogger} + # ensures that each BufferStack is only accessed from a single `Thread`. + class BufferStack + # TODO: this is only a spike for now + def with_buffer + yield Buffer.new + end + end +end diff --git a/lib/rackstash/formatter.rb b/lib/rackstash/formatter.rb new file mode 100644 index 0000000..16a06ec --- /dev/null +++ b/lib/rackstash/formatter.rb @@ -0,0 +1,28 @@ +# 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' + +module Rackstash + # The default logging formatter which is responsible for formatting a single + # {Message} for the final emitted log event. + class Formatter < ::Logger::Formatter + # Return the formatted message from the following rules: + # * Strings passed to `msg` are returned with an added newline character at + # the end + # * Exceptions are formatted with their name, message and backtrace, + # separated by newline characters. + # * All other objects will be `inspect`ed with an added newline. + # + # @param _severity [Integer] the log severity, ignored. + # @param _time [Time] the time of the log message, ignored. + # @param _progname [String] the program name, ignored. + # @param msg [String, Exception, #inspect] the log message + # @return [String] the formatted message with a final newline character + def call(_severity, _time, _progname, msg) + "#{msg2str(msg)}\n" + end + end +end diff --git a/lib/rackstash/logger.rb b/lib/rackstash/logger.rb new file mode 100644 index 0000000..5350a20 --- /dev/null +++ b/lib/rackstash/logger.rb @@ -0,0 +1,140 @@ +# 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 'forwardable' + +require 'rackstash/buffer_stack' +require 'rackstash/formatter' +require 'rackstash/message' +require 'rackstash/sink' + +module Rackstash + # The Logger is the main entry point for Rackstash. It provides an interface + # very similar to the Logger class in Ruby's Stamdard Library but extends it + # with facilities for structured logging. + class Logger + extend Forwardable + + # Logging formatter, a `Proc`-like object which take four arguments and + # returns the formatted message. The arguments are: + # + # * `severity` - The Severity of the log message. + # * `time` - A Time instance representing when the message was logged. + # * `progname` - The progname configured passed to the logger method. + # * `msg` - The `Object` the user passed to the log message; not necessarily + # a String. + # + # The formatter should return a String. When no formatter is set, an + # instance of {Formatter} is used. + # + # @return [#call] the log formatter for each individual buffered line + attr_accessor :formatter + + # @return [Integer] a numeric log level, normally you'd use one of the + # `SEVERITIES` constants, i.e. an integer between 0 and 5. + attr_reader :level + + # @return [String] the logger's progname, used as the default for log + # messages if none is passed to {#add} and passed to the {#formatter}. + # By default we use {PROGNAME}. + attr_accessor :progname + + # @return [Sink] the log {Sink} which flushes a {Buffer} to one or more + # external log targets like a file, a socket, ... + attr_reader :sink + + def initialize(targets) + @sink = Sink.new(targets) + + @level = DEBUG + @progname = PROGNAME + @formatter = Formatter.new + end + + # Set the base log level as either one of the {SEVERITIES} or a + # String/Symbol describing the level. When logging a message, it will only + # be added if its log level is at or above the base level defined here + # + # @param severity [Integer, String, Symbol] one of the {SEVERITIES} or its + # name + def level=(severity) + if severity.is_a?(Integer) + @level = severity + else + case severity.to_s.downcase + when 'debug'.freeze + @level = DEBUG + when 'info'.freeze + @level = INFO + when 'warn'.freeze + @level = WARN + when 'error'.freeze + @level = ERROR + when 'fatal'.freeze + @level = FATAL + when 'unknown'.freeze + @level = UNKNOWN + else + raise ArgumentError, "invalid log level: #{severity}" + end + end + end + + # Log a message if the given severity is high enough. This is the generic + # logging method. Users will be more inclined to use {#debug}, {#info}, + # {#warn}, {#error}, or {#fatal}. + # + # The message will be added to the current log buffer. If we are currently + # buffering (i.e. if we are inside a {#with_buffer} block), the message is + # merely added but not flushed to the underlying logger. Else, the message + # along with any previously defined fields and tags will be flushed to the + # base logger immediately. + # + # @param severity [Integer] The log severity. One of {DEBUG}, {INFO}, + # {WARN}, {ERROR}, {FATAL}, or {UNKNOWN}. + # @param msg [#to_s, Exception, nil] The log message. A `String` or + # `Exception`. If unset, we try to use the return value of the optional + # block. + # @param progname [String, nil] The program name. Can be omitted. It's + # treated as a message if no `msg` and `block` are given. + # @yield If `message` is `nil`, we yield to the block to get a message + # string. + # @return [String] The resolved unformatted message string + def add(severity, msg = nil, progname = nil) + severity = severity ? Integer(severity) : UNKNOWN + return if @level > severity + + progname ||= @progname + if msg.nil? + if block_given? + msg = yield + else + msg = progname + progname = @progname + end + end + + now = Time.now.utc.freeze + buffer_stack.with_buffer do |buffer| + buffer.add_message Message.new( + msg, + time: now, + progname: progname, + severity: severity, + formatter: formatter + ) + end + + msg + end + alias_method :log, :add + + private + + def buffer_stack + @buffer_stack ||= Rackstash::BufferStack.new + end + end +end diff --git a/lib/rackstash/message.rb b/lib/rackstash/message.rb new file mode 100644 index 0000000..2d788e6 --- /dev/null +++ b/lib/rackstash/message.rb @@ -0,0 +1,89 @@ +# 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. + +module Rackstash + # This class and all its data are immutable after initialization + class Message + RAW_FORMATTER = ->(_severity, _timestamp, _progname, msg) { msg } + + SEVERITY_LABEL = [ + 'DEBUG'.freeze, + 'INFO'.freeze, + 'WARN'.freeze, + 'ERROR'.freeze, + 'FATAL'.freeze, + 'ANY'.freeze + ].freeze + + attr_reader :message + + attr_reader :severity + + attr_reader :progname + + attr_reader :time + + attr_reader :formatter + + def initialize( + msg, + severity: UNKNOWN, + time: Time.now.utc.freeze, + progname: PROGNAME, + formatter: RAW_FORMATTER + ) + @message = cleanup_message(msg) + + @severity = Integer(severity) + @severity = 0 if @severity < 0 + + @time = dup_freeze(time) + @progname = dup_freeze(progname) + @formatter = formatter + end + + def severity_label + SEVERITY_LABEL[@severity] || SEVERITY_LABEL.last + end + + def frozen? + true + end + + def to_s + @to_s ||= @formatter.call(severity_label, @time, @progname, @message) + end + alias_method :to_str, :to_s + alias_method :as_json, :to_s + + private + + # Sanitize a single mesage to be added to the buffer, can be a single or + # multi line string + # + # @param msg [#to_s] a message to be added to the buffer + # @return [String] the sanitized frozen message + def cleanup_message(msg) + msg = msg.inspect unless msg.is_a?(String) + msg = utf8_encode(msg) + # remove useless ANSI color codes + msg.gsub!(/\e\[[0-9;]*m/, EMPTY_STRING) + msg.freeze + end + + def utf8_encode(str) + str.to_s.encode( + Encoding::UTF_8, + invalid: :replace, + undef: :replace, + universal_newline: true + ) + end + + def dup_freeze(obj) + obj.frozen? ? obj : obj.dup.freeze + end + end +end diff --git a/lib/rackstash/sink.rb b/lib/rackstash/sink.rb new file mode 100644 index 0000000..66ff9cc --- /dev/null +++ b/lib/rackstash/sink.rb @@ -0,0 +1,20 @@ +# 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. + +module Rackstash + class Sink + attr_reader :targets + + def initialize(targets) + @targets = targets.respond_to?(:to_ary) ? targets.to_ary : [targets] + end + + def flush(buffer) + @targets.each do |target| + target.flush(buffer) + end + end + end +end diff --git a/spec/rackstash/buffer_spec.rb b/spec/rackstash/buffer_spec.rb new file mode 100644 index 0000000..3e2b21a --- /dev/null +++ b/spec/rackstash/buffer_spec.rb @@ -0,0 +1,38 @@ +# 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 'rackstash/buffer' + +describe Rackstash::Buffer do + let(:buffer) { Rackstash::Buffer.new } + + describe '#add_message' do + it 'adds a message to the buffer' do + msg = double(message: 'Hello World') + buffer.add_message msg + + expect(buffer.messages).to eql [msg] + end + end + + describe 'messages' do + it 'returns an array of messages' do + msg = double('Rackstash::Message') + buffer.add_message(msg) + + expect(buffer.messages).to eql [msg] + end + + it 'returns a new array each time' do + expect(buffer.messages).not_to equal buffer.messages + + expect(buffer.messages).to eql [] + buffer.messages << 'invalid' + expect(buffer.messages).to eql [] + end + end +end diff --git a/spec/rackstash/buffer_stack_spec.rb b/spec/rackstash/buffer_stack_spec.rb new file mode 100644 index 0000000..8526349 --- /dev/null +++ b/spec/rackstash/buffer_stack_spec.rb @@ -0,0 +1,20 @@ +# 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 'rackstash/buffer_stack' + +describe Rackstash::BufferStack do + let(:stack) { Rackstash::BufferStack.new } + + describe '#with_buffer' do + it 'initializes a buffer' do + stack.with_buffer do |buffer| + expect(buffer).to be_a Rackstash::Buffer + end + end + end +end diff --git a/spec/rackstash/formatter_spec.rb b/spec/rackstash/formatter_spec.rb new file mode 100644 index 0000000..09e7fd6 --- /dev/null +++ b/spec/rackstash/formatter_spec.rb @@ -0,0 +1,48 @@ +# 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 'time' +require 'rackstash/formatter' + +describe Rackstash::Formatter do + let(:formatter) { Rackstash::Formatter.new } + + it 'formats plain strings' do + expect(formatter.call('ERROR', Time.now, 'progname', 'Hello')).to eql "Hello\n" + end + + it 'formats stringifiable objects' do + expect(formatter.call('ERROR', Time.now, 'progname', 123)).to eql "123\n" + end + + it 'formats Hashes' do + expect(formatter.call('ERROR', Time.now, 'progname', { k: 'v' })).to eql "{:k=>\"v\"}\n" + end + + it 'formats exceptions' do + exception = nil + begin + raise StandardError, 'An Error' + rescue => e + exception = e + end + + checker = Regexp.new <<-EOF.gsub(/^\s+/, '').rstrip, Regexp::MULTILINE + \\AAn Error \\(StandardError\\) + #{Regexp.escape __FILE__}:#{__LINE__ - 7}:in `block .*` + EOF + expect(formatter.call('ERROR', Time.now, 'progname', exception)).to match checker + end + + it 'inspects unknown objects' do + object = Object.new + inspected = object.inspect + + expect(object).to receive(:inspect).once.and_call_original + expect(formatter.call('ERROR', Time.now, 'progname', object)).to eql "#{inspected}\n" + end +end diff --git a/spec/rackstash/logger_spec.rb b/spec/rackstash/logger_spec.rb new file mode 100644 index 0000000..81ef9d8 --- /dev/null +++ b/spec/rackstash/logger_spec.rb @@ -0,0 +1,219 @@ +# 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 'rackstash/logger' + +describe Rackstash::Logger do + let(:targets) { double('targets') } + let(:logger) { Rackstash::Logger.new(targets) } + + describe '#formatter' do + it 'defaults to a Rackstash::Formatter' do + expect(logger.formatter).to be_a Rackstash::Formatter + end + + it 'allows to set a custom formatter' do + formatter = ->(_severity, _time, _progname, msg) { msg.reverse } + logger.formatter = formatter + expect(logger.formatter).to equal formatter + end + end + + describe '#level' do + it 'defaults to DEBUG' do + expect(logger.level).to eql 0 + end + + it 'can be set as an integer' do + logger.level = 3 + expect(logger.level).to eql 3 + + logger.level = 42 + expect(logger.level).to eql 42 + + logger.level = -25 + expect(logger.level).to eql -25 + + end + + it 'can be set as a symbol' do + %i[debug info warn error fatal unknown].each_with_index do |level, i| + logger.level = level + expect(logger.level).to eql i + end + + %i[DeBuG InFo WaRn ErRoR FaTaL UnKnOwN].each_with_index do |level, i| + logger.level = level + expect(logger.level).to eql i + end + end + + it 'can be set as a string' do + %w[debug info warn error fatal unknown].each_with_index do |level, i| + logger.level = level + expect(logger.level).to eql i + end + + %w[DeBuG InFo WaRn ErRoR FaTaL UnKnOwN].each_with_index do |level, i| + logger.level = level + expect(logger.level).to eql i + end + end + + it 'rejects invalid values' do + expect { logger.level = 'invalid' }.to raise_error(ArgumentError) + expect { logger.level = Object.new }.to raise_error(ArgumentError) + expect { logger.level = nil }.to raise_error(ArgumentError) + expect { logger.level = false }.to raise_error(ArgumentError) + expect { logger.level = true }.to raise_error(ArgumentError) + end + end + + describe '#progname' do + it 'defaults to PROGNAME' do + expect(logger.progname).to match %r{\Arackstash/v\d+(\..+)*\z} + end + + it 'can be set to a custom value' do + logger.progname = 'my app' + expect(logger.progname).to eql 'my app' + end + end + + describe '#sink' do + it 'returns the created sink' do + expect(logger.sink).to be_a Rackstash::Sink + end + end + + describe '#add' do + let(:messages) { [] } + + let(:buffer) { + double('Rackstash::Buffer').tap do |buffer| + expect(buffer).to receive(:add_message) { |message| messages << message } + .at_least(:once) + end + } + + let(:buffer_stack) { + double('Rackstash::BufferStack').tap do |buffer_stack| + expect(buffer_stack).to receive(:with_buffer) + .at_least(:once) + .and_yield(buffer) + end + } + + before(:each) do + class_double('Rackstash::Message').as_stubbed_const.tap do |klass| + expect(klass).to receive(:new) { |msg, **kwargs| {message: msg, **kwargs} } + .at_least(:once) + end + expect(logger).to receive(:buffer_stack) + .at_least(:once) + .and_return(buffer_stack) + end + + it 'sets the current time as UTC to the message' do + logger.add(nil, 'msg') + expect(messages.last[:time]).to be_a(Time).and be_frozen.and be_utc + end + + it 'sets the provided a severity' do + logger.log(Rackstash::DEBUG, 'Debug message') + expect(messages.last).to include message: 'Debug message', severity: 0 + + logger.log(Rackstash::INFO, 'Info message') + expect(messages.last).to include message: 'Info message', severity: 1 + + logger.log(Rackstash::WARN, 'Warn message') + expect(messages.last).to include message: 'Warn message', severity: 2 + + logger.log(Rackstash::ERROR, 'Error message') + expect(messages.last).to include message: 'Error message', severity: 3 + + logger.log(Rackstash::FATAL, 'Fatal message') + expect(messages.last).to include message: 'Fatal message', severity: 4 + + logger.log(Rackstash::UNKNOWN, 'Unknown message') + expect(messages.last).to include message: 'Unknown message', severity: 5 + + # Positive severities are passed along + logger.log(42, 'The answer') + expect(messages.last).to include message: 'The answer', severity: 42 + + # nil is changed to UNKNOWN + logger.log(nil, 'Missing') + expect(messages.last).to include message: 'Missing', severity: 5 + + # Non-number arguments result in an error + expect { logger.log(:debug, 'Missing') }.to raise_error(TypeError) + expect { logger.log('debug', 'Missing') }.to raise_error(ArgumentError) + end + + it 'defaults to severity to UNKNOWN' do + logger.add(nil, 'test') + expect(messages.last).to include severity: 5 + end + + it 'calls the block if message is nil' do + temp = 0 + expect do + logger.log(nil, nil, 'TestApp') do + temp = 1 + 1 + end + end.to_not raise_error + expect(temp).to eql 2 + end + + it 'ignores the block if the message is not nil' do + temp = 0 + expect do + logger.log(nil, 'not nil', 'TestApp') do + temp = 1 + 1 + end + end.to_not raise_error + expect(temp).to eql 0 + end + + it 'follows Ruby\'s logger logic to find the message' do + # If there is a message, it will be logged + logger.add(0, 'Hello', nil) + expect(messages.last).to include message: 'Hello', severity: 0, progname: Rackstash::PROGNAME + + logger.add(4, 'Hello', 'prog') + expect(messages.last).to include message: 'Hello', severity: 4, progname: 'prog' + + logger.add(5, 'Hello', 'prog') { 'block' } + expect(messages.last).to include message: 'Hello', severity: 5, progname: 'prog' + + logger.add(nil, 'Hello', nil) + expect(messages.last).to include message: 'Hello', severity: 5, progname: Rackstash::PROGNAME + + # If there is no message, we use the block + logger.add(1, nil, 'prog') { 'Hello' } + expect(messages.last).to include message: 'Hello', severity: 1, progname: 'prog' + logger.add(1, nil, nil) { 'Hello' } + expect(messages.last).to include message: 'Hello', severity: 1, progname: Rackstash::PROGNAME + + # If there is no block either, we use the progname and pass the default + # progname to the message + logger.add(2, nil, 'prog') + expect(messages.last).to include message: 'prog', severity: 2, progname: Rackstash::PROGNAME + # ... which defaults to `Rackstash::BufferedLogger::PROGNAME` + logger.add(3, nil, nil) + expect(messages.last).to include message: Rackstash::PROGNAME, severity: 3, progname: Rackstash::PROGNAME + + # If we resolve the message to a blank string, we still add it + logger.add(1, '', nil) { 'Hello' } + expect(messages.last).to include message: '', severity: 1, progname: Rackstash::PROGNAME + # Same with nil which is later inspect'ed by the formatter + logger.add(0, nil, 'prog') { nil } + expect(messages.last).to include message: nil, severity: 0, progname: 'prog' + end + end +end diff --git a/spec/rackstash/message_spec.rb b/spec/rackstash/message_spec.rb new file mode 100644 index 0000000..4563548 --- /dev/null +++ b/spec/rackstash/message_spec.rb @@ -0,0 +1,159 @@ +# 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 'digest' +require 'rackstash/message' + +describe Rackstash::Message do + describe '#message' do + it 'dups the message string' do + str = 'a message' + message = Rackstash::Message.new(str) + + expect(message.message).to eql str + expect(message.message).not_to equal str + expect(message.message).to be_frozen + end + + it 'cleans the message' do + messages = [ + ["First\r\nSecond", "First\nSecond"], + ["First\r\nSecond\n\r", "First\nSecond\n\n"], + ["Foo\r\n\rBar", "Foo\n\nBar"], + ["\r \tWord\n\nPhrase\n", "\n \tWord\n\nPhrase\n"], + ["\e[31mRED TEXT\e[0m", 'RED TEXT'] + ] + + messages.each do |msg, clean| + message = Rackstash::Message.new(msg) + expect(message.message).to eql clean + end + end + + it 'encodes the message as UTF-8' do + utf8_str = 'Dönerstraße' + latin_str = utf8_str.encode(Encoding::ISO8859_9) + expect(latin_str.encoding).to eql Encoding::ISO8859_9 + + message = Rackstash::Message.new(latin_str) + expect(message.message).to eql utf8_str + expect(message.message.encoding).to eql Encoding::UTF_8 + end + + it 'does not raise an error on incompatible encodings' do + binary = Digest::SHA256.digest('string') + message = Rackstash::Message.new(binary) + + expect(message.message).to include '�' + end + end + + describe '#severity' do + it 'defaults to UNKNOWN' do + expect(Rackstash::Message.new('').severity).to eql 5 + end + + it 'accepts any non-negative integer' do + expect(Rackstash::Message.new('', severity: 0).severity).to eql 0 + expect(Rackstash::Message.new('', severity: 1).severity).to eql 1 + expect(Rackstash::Message.new('', severity: 23).severity).to eql 23 + expect(Rackstash::Message.new('', severity: '3').severity).to eql 3 + end + + it 'uses 0 for negative severities' do + expect(Rackstash::Message.new('', severity: -1).severity).to eql 0 + expect(Rackstash::Message.new('', severity: -42).severity).to eql 0 + end + + it 'does not accept non-integers' do + expect { Rackstash::Message.new('', severity: nil) }.to raise_error TypeError + expect { Rackstash::Message.new('', severity: [42]) }.to raise_error TypeError + expect { Rackstash::Message.new('', severity: 'foo') }.to raise_error ArgumentError + end + end + + describe '#severity_label' do + it 'formats the given severity as a string' do + %w[DEBUG INFO WARN ERROR FATAL ANY].each_with_index do |label, severity| + expect(Rackstash::Message.new('', severity: severity).severity_label).to eql label + end + end + + it 'returns ANY for unknown severities' do + expect(Rackstash::Message.new('', severity: 42).severity_label).to eql 'ANY' + end + end + + describe 'progname' do + it 'dups the progname' do + progname = 'a message' + message = Rackstash::Message.new('', progname: progname) + + expect(message.progname).to eql progname + expect(message.progname).not_to equal progname + expect(message.progname).to be_frozen + end + + it 'defaults to PROGNAME' do + expect(Rackstash::Message.new('').progname).to match %r{\Arackstash/v\d+(\..+)*\z} + end + end + + describe 'time' do + it 'dups the time' do + time = Time.now + message = Rackstash::Message.new('', time: time) + + expect(message.time).to eql time + expect(message.time).not_to equal time + expect(message.time).to be_frozen + # User-supplied time is not enforced to be in UTC + expect(message.time).to_not be_utc + end + + it 'defaults to Time.now.utc' do + expect(Rackstash::Message.new('').time).to be_within(1).of(Time.now) + expect(Rackstash::Message.new('').time).to be_frozen + expect(Rackstash::Message.new('').time).to be_utc + end + end + + describe 'formatter' do + it 'defaults to RAW_FORMATTER' do + expect(Rackstash::Message.new('').formatter).to equal Rackstash::Message::RAW_FORMATTER + + message = Rackstash::Message.new('Beep boop') + expect(message.to_s).to equal message.message + end + end + + describe '#to_s' do + it 'formats the message' do + severity = 0 + time = Time.now + progname = 'ProgramName' + message = 'Hello World' + + formatter = double('formatter') + expect(formatter).to receive(:call).once + .with('DEBUG', time, progname, message) + .and_return('Formatted Message') + + message = Rackstash::Message.new( + message, + severity: severity, + time: time, + progname: progname, + formatter: formatter + ) + + expect(message.to_s).to eql 'Formatted Message' + # Same result, but no additional call to the formatter + expect(message.to_s).to eql 'Formatted Message' + end + end +end diff --git a/spec/rackstash/sink_spec.rb b/spec/rackstash/sink_spec.rb new file mode 100644 index 0000000..1ea79c9 --- /dev/null +++ b/spec/rackstash/sink_spec.rb @@ -0,0 +1,37 @@ +# 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 'rackstash/sink' + +describe Rackstash::Sink do + let(:targets) { [] } + let(:sink) { Rackstash::Sink.new(targets) } + + describe '#initialize' do + it 'accepts an array with targets' do + expect(targets).to receive(:to_ary).once.and_call_original + expect(sink.targets).to equal targets + end + + it 'wraps a single target into an array' do + target = Object.new + expect(Rackstash::Sink.new(target).targets).to eql [target] + end + end + + describe '#flush' do + it 'flushes the buffer to all targets' do + buffer = double('buffer') + + target = double('target') + targets << target + + expect(target).to receive(:flush).with(buffer) + sink.flush(buffer) + end + end +end diff --git a/spec/rackstash_spec.rb b/spec/rackstash_spec.rb index 3ffab61..534a8c0 100644 --- a/spec/rackstash_spec.rb +++ b/spec/rackstash_spec.rb @@ -6,4 +6,18 @@ require 'spec_helper' describe Rackstash do + it 'defines PROGRAME with the correct version' do + expect(Rackstash::PROGNAME).to match %r{\Arackstash/v\d+(\..+)*\z} + end + + it 'defines SEVERITIES constants' do + expect(Rackstash::SEVERITIES).to eql (0..5).to_a + + expect(Rackstash::DEBUG).to eql 0 + expect(Rackstash::INFO).to eql 1 + expect(Rackstash::WARN).to eql 2 + expect(Rackstash::ERROR).to eql 3 + expect(Rackstash::FATAL).to eql 4 + expect(Rackstash::UNKNOWN).to eql 5 + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 517e9a5..76b84d0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,3 +5,13 @@ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) require 'rackstash' + +RSpec.configure do |config| + config.mock_with :rspec do |mocks| + # This option should be set when all dependencies are being loaded + # before a spec run, as is the case in a typical spec helper. It will + # cause any verifying double instantiation for a class that does not + # exist to raise, protecting against incorrectly spelt names. + mocks.verify_doubled_constant_names = true + end +end