diff --git a/lib/rackstash/encoders.rb b/lib/rackstash/encoders.rb index 072622a..909059a 100644 --- a/lib/rackstash/encoders.rb +++ b/lib/rackstash/encoders.rb @@ -5,6 +5,7 @@ # of the MIT license. See the LICENSE.txt file for details. require 'rackstash/encoders/json' +require 'rackstash/encoders/lograge' require 'rackstash/encoders/logstash' require 'rackstash/encoders/message' require 'rackstash/encoders/raw' diff --git a/lib/rackstash/encoders/lograge.rb b/lib/rackstash/encoders/lograge.rb new file mode 100644 index 0000000..047cc46 --- /dev/null +++ b/lib/rackstash/encoders/lograge.rb @@ -0,0 +1,139 @@ +# 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 'rackstash/encoders/helpers/timestamp' + +module Rackstash + module Encoders + # The Lograge encoder formats the log event in the original key-value format + # of the [lograge gem](https://github.com/roidrage/lograge). + # + # The fields of the passed event are serielized to simple `key=value` pairs, + # separated by a single space each. The following formatting rules apply: + # + # * Field names for nested Hashes and Arrays are separated by a dot to form + # a unique key name + # * Arrays are formatted the same as nested hashes, using the value's index + # in the array in the field name for each value. + # * Floats are formatted with 2 decimal digits + # * Newlines, dots, equals signs or spaces in keys and values are preserved + # and not escaped in any way (apart from the `"error"` field, see below). + # You might thus want to avoid any whitespace charactes in general and + # dots or equals signs in hash keys. You can use filters in your {Flow} to + # ensure suitable field names. + # * The `"message"` and `"error_trace"` fields are never included in the + # output. + # * If there is an error in the event hash, we will generate a quoted string + # from the `"error"` and `"error_message"` fields. + # + # Given the following event + # + # { + # "@timestamp" => Time.utc(2017, 4, 18, 23, 21, 58), + # "message" => ["This is ignored"], + # "foo" => ["bar", "baz"], + # "beep" => { + # "pling" => "plong", + # "toot" => "chirp" + # }, + # "runtime" => 3.14159 + # } + # + # the encoder will output the following log line: + # + # timestamp=2017-04-18T23:21:58.000000Z foo.0=bar foo.1=baz beep.pling=plong beep.toot=chirp runtime=3.14 + # + # With an error in the event hash, e.g. like this + # + # { + # "@timestamp" => Time.utc(2017, 4, 18, 23, 21, 58), + # "message" => ["This is ignored"], + # "error" => "RuntimeError", + # "error_message" => "Something bad happened", + # "error_trace" => "my_lib.rb:5:in `broken_method'\nmy_lib.rb:10:in `
'" + # } + # + # the encoder will output the following log line: + # + # timestamp=2017-04-18T23:21:58.000000Z error='RuntimeError: Something bad happened' + # + class Lograge + include Rackstash::Encoders::Helpers::Timestamp + + SKIP = [ + FIELD_MESSAGE, + FIELD_ERROR_TRACE + ].freeze + + # @param event [Hash] a log event as produced by the {Flow} + # @return [String] a log line with formatted key-value pairs + def encode(event) + normalize_timestamp(event) + + format_error(event) + skip_fields(event) + + serialize_hash(event) + end + + private + + def format_error(event) + error = event[FIELD_ERROR] + error_message = event.delete(FIELD_ERROR_MESSAGE) + + event[FIELD_ERROR] = + if error.nil? + error_message.nil? ? nil : "'#{error_message}'" + else + error_message.nil? ? "'#{error}'" : "'#{error}: #{error_message}'" + end + end + + def skip_fields(event) + SKIP.each do |field| + event.delete(field) + end + end + + def serialize_hash(hash, prefix: nil) + hash.map { |key, value| + serialize_pair(key, value, prefix) + }.compact.join(' '.freeze) + end + + def serialize_array(array, prefix: nil) + array.each_with_index.map { |value, index| + serialize_pair(index.to_s, value, prefix) + }.compact.join(' '.freeze) + end + + def serialize_pair(key, value, prefix) + if prefix + key = "#{prefix}.#{key}" + elsif key == FIELD_TIMESTAMP + # Use 'timestamp' instead of '@timestamp' on the top-level + key = 'timestamp' + end + + case value + when nil + return + when Hash + return if value.empty? + return serialize_hash(value, prefix: key) + when Array + return if value.empty? + return serialize_array(value, prefix: key) + when Float + value = Kernel.format('%.2f'.freeze, value) + end + + "#{key}=#{value}" + end + end + end +end diff --git a/spec/rackstash/encoders/lograge_spec.rb b/spec/rackstash/encoders/lograge_spec.rb new file mode 100644 index 0000000..84fec97 --- /dev/null +++ b/spec/rackstash/encoders/lograge_spec.rb @@ -0,0 +1,70 @@ +# 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 'rackstash/encoders/lograge' + +describe Rackstash::Encoders::Lograge do + let(:encoder) { described_class.new } + + describe '#encode' do + it 'formats the timestamp if present' do + event = { '@timestamp' => Time.new(2016, 10, 17, 16, 37, 0, '+03:00') } + expect(encoder.encode(event)) + .to eql 'timestamp=2016-10-17T13:37:00.000000Z' + end + + it 'formats multiple values' do + event = { 'pling' => 'plong', 'toot' => 'chirp' } + expect(encoder.encode(event)) + .to eql 'pling=plong toot=chirp' + end + + it 'formats nested objects' do + event = { 'pling' => ['plong', nil, { 'toot' => { 'bird' => ['chirp', 'tweet'] } }] } + expect(encoder.encode(event)) + .to eql 'pling.0=plong pling.2.toot.bird.0=chirp pling.2.toot.bird.1=tweet' + end + + it 'formats float values' do + event = { 'key' => 3.14159, 'rounded' => 4.947 } + expect(encoder.encode(event)).to eql 'key=3.14 rounded=4.95' + end + + it 'formats complex errors' do + event = { + 'error' => 'RuntimeError', + 'error_message' => 'Something', + 'error_trace' => "Foo\nBar\nBaz", + + 'nested' => { + 'error' => 'NestedError', + 'error_message' => 'a message' + } + } + + expect(encoder.encode(event)) + .to eql "error='RuntimeError: Something' nested.error=NestedError nested.error_message=a message" + end + + it 'formats an error' do + event = { 'error' => 'StandardError' } + expect(encoder.encode(event)).to eql "error='StandardError'" + end + + it 'formats an error_message' do + event = { 'error_message' => 'Something happened' } + expect(encoder.encode(event)).to eql "error='Something happened'" + end + + it 'ignores dots, spaces and equal signs' do + event = { 'some.key' => 'a.value', 'a=b' => 'c=d', 'a key' => 'some value' } + expect(encoder.encode(event)) + .to eql 'some.key=a.value a=b=c=d a key=some value' + end + end +end