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