tty-logger 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/tty/logger.rb CHANGED
@@ -1,10 +1,216 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "logger/config"
4
+ require_relative "logger/event"
5
+ require_relative "logger/formatters/json"
6
+ require_relative "logger/formatters/text"
7
+ require_relative "logger/handlers/console"
8
+ require_relative "logger/handlers/null"
9
+ require_relative "logger/handlers/stream"
10
+ require_relative "logger/levels"
3
11
  require_relative "logger/version"
4
12
 
5
13
  module TTY
6
14
  class Logger
15
+ include Levels
16
+
17
+ # Error raised by this logger
7
18
  class Error < StandardError; end
8
19
 
20
+ # Logger configuration instance
21
+ #
22
+ # @api public
23
+ def self.config
24
+ @config ||= Config.new
25
+ end
26
+
27
+ # Global logger configuration
28
+ #
29
+ # @api public
30
+ def self.configure
31
+ yield config
32
+ end
33
+
34
+ # Create a logger instance
35
+ #
36
+ # @example
37
+ # logger = TTY::Logger.new(output: $stdout)
38
+ #
39
+ # @api public
40
+ def initialize(output: nil, fields: {})
41
+ @fields = fields
42
+ @config = if block_given?
43
+ conf = Config.new
44
+ yield(conf)
45
+ conf
46
+ else
47
+ self.class.config
48
+ end
49
+ @level = @config.level
50
+ @handlers = @config.handlers
51
+ @output = output || @config.output
52
+ @ready_handlers = []
53
+
54
+ @handlers.each do |handler|
55
+ add_handler(handler)
56
+ end
57
+ end
58
+
59
+ # Add handler for logging messages
60
+ #
61
+ # @example
62
+ # add_handler(:console)
63
+ #
64
+ # @api public
65
+ def add_handler(handler)
66
+ h, options = *(handler.is_a?(Array) ? handler : [handler, {}])
67
+ name = coerce_handler(h)
68
+ global_opts = { output: @output, config: @config }
69
+ opts = global_opts.merge(options)
70
+ ready_handler = name.new(opts)
71
+ @ready_handlers << ready_handler
72
+ end
73
+
74
+ # Remove log events handler
75
+ #
76
+ # @example
77
+ # remove_handler(:console)
78
+ #
79
+ # @api public
80
+ def remove_handler(handler)
81
+ @ready_handlers.delete(handler)
82
+ end
83
+
84
+ # Coerce handler name into object
85
+ #
86
+ # @example
87
+ # coerce_handler(:console)
88
+ # # => TTY::Logger::Handlers::Console
89
+ #
90
+ # @raise [Error] when class cannot be coerced
91
+ #
92
+ # @return [Class]
93
+ #
94
+ # @api private
95
+ def coerce_handler(name)
96
+ case name
97
+ when String, Symbol
98
+ Handlers.const_get(name.capitalize)
99
+ when Class
100
+ name
101
+ else
102
+ raise_handler_error
103
+ end
104
+ rescue NameError
105
+ raise_handler_error
106
+ end
107
+
108
+ # Raise error when unknown handler name
109
+ #
110
+ # @api private
111
+ def raise_handler_error
112
+ raise Error, "Handler needs to be a class name or a symbol name"
113
+ end
114
+
115
+ # Add structured data
116
+ #
117
+ # @example
118
+ # logger = TTY::Logger.new
119
+ # logger.with(app: "myenv", env: "prod").debug("Deplying")
120
+ #
121
+ # @return [TTY::Logger]
122
+ # a new copy of this logger
123
+ #
124
+ # @api public
125
+ def with(new_fields)
126
+ self.class.new(fields: @fields.merge(new_fields), output: @output)
127
+ end
128
+
129
+ # Check current level against another
130
+ #
131
+ # @return [Symbol]
132
+ #
133
+ # @api public
134
+ def log?(level, other_level)
135
+ compare_levels(level, other_level) != :gt
136
+ end
137
+
138
+ # Log a message given the severtiy level
139
+ #
140
+ # @api public
141
+ def log(current_level, *msg, **scoped_fields)
142
+ if msg.empty? && block_given?
143
+ msg = [yield]
144
+ end
145
+ loc = caller_locations(2,1)[0]
146
+ metadata = {
147
+ level: current_level,
148
+ time: Time.now,
149
+ pid: Process.pid,
150
+ name: caller_locations(1,1)[0].label,
151
+ path: loc.path,
152
+ lineno: loc.lineno,
153
+ method: loc.base_label
154
+ }
155
+ event = Event.new(msg, @fields.merge(scoped_fields), metadata)
156
+ @ready_handlers.each do |handler|
157
+ level = handler.respond_to?(:level) ? handler.level : @config.level
158
+ handler.(event) if log?(level, current_level)
159
+ end
160
+ self
161
+ end
162
+
163
+ # Log a message at :debug level
164
+ #
165
+ # @api public
166
+ def debug(*msg, &block)
167
+ log(:debug, *msg, &block)
168
+ end
169
+
170
+ # Log a message at :info level
171
+ #
172
+ # @examples
173
+ # logger.info "Successfully deployed"
174
+ # logger.info { "Dynamically generated info" }
175
+ #
176
+ # @api public
177
+ def info(*msg, &block)
178
+ log(:info, *msg, &block)
179
+ end
180
+
181
+ # Log a message at :warn level
182
+ #
183
+ # @api public
184
+ def warn(*msg, &block)
185
+ log(:warn, *msg, &block)
186
+ end
187
+
188
+ # Log a message at :error level
189
+ #
190
+ # @api public
191
+ def error(*msg, &block)
192
+ log(:error, *msg, &block)
193
+ end
194
+
195
+ # Log a message at :fatal level
196
+ #
197
+ # @api public
198
+ def fatal(*msg, &block)
199
+ log(:fatal, *msg, &block)
200
+ end
201
+
202
+ # Log a message with a success label
203
+ #
204
+ # @api public
205
+ def success(*msg, &block)
206
+ log(:info, *msg, &block)
207
+ end
208
+
209
+ # Log a message with a wait label
210
+ #
211
+ # @api public
212
+ def wait(*msg, &block)
213
+ log(:info, *msg, &block)
214
+ end
9
215
  end # Logger
10
216
  end # TTY
data/spec/spec_helper.rb CHANGED
@@ -1,3 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ if ENV['COVERAGE'] || ENV['TRAVIS']
4
+ require 'simplecov'
5
+ require 'coveralls'
6
+
7
+ SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([
8
+ SimpleCov::Formatter::HTMLFormatter,
9
+ Coveralls::SimpleCov::Formatter
10
+ ])
11
+
12
+ SimpleCov.start do
13
+ command_name 'spec'
14
+ add_filter 'spec'
15
+ end
16
+ end
17
+
1
18
  require "bundler/setup"
2
19
  require "tty-logger"
3
20
 
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe TTY::Logger, "#add_handler" do
4
+ let(:output) { StringIO.new }
5
+ let(:styles) { TTY::Logger::Handlers::Console::STYLES }
6
+
7
+ it "dynamically adds and removes a handler object" do
8
+ logger = TTY::Logger.new(output: output) do |config|
9
+ config.handlers = []
10
+ end
11
+
12
+ logger.info("No handler")
13
+
14
+ logger.add_handler :console
15
+
16
+ logger.info("Console handler")
17
+
18
+ logger.remove_handler :console
19
+
20
+ expect(output.string).to eq([
21
+ "\e[32m#{styles[:info][:symbol]}\e[0m ",
22
+ "\e[32minfo\e[0m ",
23
+ "Console handler \n"].join)
24
+ end
25
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe TTY::Logger::Config do
4
+ let(:output) { StringIO.new }
5
+ let(:styles) { TTY::Logger::Handlers::Console::STYLES }
6
+
7
+ it "defaults :max_bytes to 8192" do
8
+ config = described_class.new
9
+ expect(config.max_bytes).to eq(8192)
10
+ end
11
+
12
+ it "defaults :max_depth to 3" do
13
+ config = described_class.new
14
+ expect(config.max_depth).to eq(3)
15
+ end
16
+
17
+ it "defaults :level to :info" do
18
+ config = described_class.new
19
+ expect(config.level).to eq(:info)
20
+ end
21
+
22
+ it "sets :max_bytes" do
23
+ config = described_class.new
24
+ config.max_bytes = 2**8
25
+ expect(config.max_bytes).to eq(256)
26
+ end
27
+
28
+ it "defaults metadata to empty array" do
29
+ config = described_class.new
30
+ expect(config.metadata).to eq([])
31
+ end
32
+
33
+ it "defaults handlers to console" do
34
+ config = described_class.new
35
+ expect(config.handlers).to eq([:console])
36
+ end
37
+
38
+ it "defaults formatter to text" do
39
+ config = described_class.new
40
+ expect(config.formatter).to eq(:text)
41
+ end
42
+
43
+ it "defaults date format to %F" do
44
+ config = described_class.new
45
+ expect(config.date_format).to eq("%F")
46
+ end
47
+
48
+ it "defaults output to stderr" do
49
+ config = described_class.new
50
+ expect(config.output).to eq($stderr)
51
+ end
52
+
53
+ it "serializes data into hash" do
54
+ config = described_class.new
55
+ expect(config.to_h).to eq({
56
+ date_format: "%F",
57
+ formatter: :text,
58
+ handlers: [:console],
59
+ level: :info,
60
+ max_bytes: 8192,
61
+ max_depth: 3,
62
+ metadata: [],
63
+ output: $stderr,
64
+ time_format: "%T.%3N"
65
+ })
66
+ end
67
+
68
+ it "yields configuration instance" do
69
+ config = double(:config)
70
+ allow(TTY::Logger).to receive(:config).and_return(config)
71
+ expect { |block|
72
+ TTY::Logger.configure(&block)
73
+ }.to yield_with_args(config)
74
+ end
75
+
76
+ it "configures output size" do
77
+ logger = TTY::Logger.new(output: output) do |config|
78
+ config.max_bytes = 2**4
79
+ config.level = :debug
80
+ end
81
+
82
+ logger.debug("Deploying", app: "myapp", env: "prod")
83
+
84
+ expect(output.string).to eq([
85
+ "\e[36m#{styles[:debug][:symbol]}\e[0m ",
86
+ "\e[36mdebug\e[0m ",
87
+ "Deploying ",
88
+ "\e[36mapp\e[0m=myapp ...\n"
89
+ ].join)
90
+ end
91
+
92
+ it "configures maximum depth of structured data" do
93
+ logger = TTY::Logger.new(output: output) do |config|
94
+ config.max_depth = 1
95
+ config.level = :debug
96
+ end
97
+
98
+ logger.debug("Deploying", app: "myapp", env: { name: "prod" })
99
+
100
+ expect(output.string).to eq([
101
+ "\e[36m#{styles[:debug][:symbol]}\e[0m ",
102
+ "\e[36mdebug\e[0m ",
103
+ "Deploying ",
104
+ "\e[36mapp\e[0m=myapp \e[36menv\e[0m={...}\n"
105
+ ].join)
106
+ end
107
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe TTY::Logger::Event, "event" do
4
+ it "defaults backtrace to an empty array" do
5
+ event = described_class.new(["message"], {}, {})
6
+ expect(event.backtrace).to eq([])
7
+ end
8
+
9
+ it "extracts backtrace if message contains exception" do
10
+ event = nil
11
+ error = nil
12
+
13
+ begin
14
+ raise ArgumentError, "Wrong data"
15
+ rescue => ex
16
+ error = ex
17
+ event = described_class.new(["Error", ex], {}, {})
18
+ end
19
+
20
+ expect(event.backtrace.join).to eq(error.backtrace.join)
21
+ end
22
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe TTY::Logger, "exception logging" do
4
+ let(:output) { StringIO.new }
5
+ let(:styles) { TTY::Logger::Handlers::Console::STYLES }
6
+
7
+ it "handles exception type message when console" do
8
+ logger = TTY::Logger.new(output: output)
9
+ error = nil
10
+
11
+ begin
12
+ raise ArgumentError, "Wrong data"
13
+ rescue => ex
14
+ error = ex
15
+ logger.fatal("Error:", error)
16
+ end
17
+
18
+ expect(output.string).to eq([
19
+ "\e[31m#{styles[:fatal][:symbol]}\e[0m ",
20
+ "\e[31mfatal\e[0m ",
21
+ "Error: Wrong data \n",
22
+ "#{error.backtrace.map {|bktrace| bktrace.to_s.insert(0, " " * 4) }.join("\n")}\n"
23
+ ].join)
24
+ end
25
+
26
+ it "handles exception type message when stream" do
27
+ logger = TTY::Logger.new(output: output) do |config|
28
+ config.handlers = [:stream]
29
+ end
30
+
31
+ error = nil
32
+
33
+ begin
34
+ raise ArgumentError, "Wrong data"
35
+ rescue => ex
36
+ error = ex
37
+ logger.fatal("Error:", error)
38
+ end
39
+
40
+ expect(output.string).to eq([
41
+ "level=fatal message=\"Error: Wrong data\" backtrace=\"",
42
+ "#{error.backtrace.map {|bktrace| bktrace }.join(",")}\"\n"
43
+ ].join)
44
+ end
45
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe TTY::Logger, "formatter" do
4
+ let(:output) { StringIO.new }
5
+ let(:styles) { TTY::Logger::Handlers::Console::STYLES }
6
+
7
+ it "changes default formatter to JSON as class name" do
8
+ logger = TTY::Logger.new(output: output) do |config|
9
+ config.formatter = TTY::Logger::Formatters::JSON
10
+ end
11
+
12
+ logger.info("Logging", app: "myapp", env: "prod")
13
+
14
+ expect(output.string).to eq([
15
+ "\e[32m#{styles[:info][:symbol]}\e[0m ",
16
+ "\e[32minfo\e[0m ",
17
+ "Logging ",
18
+ "{\"\e[32mapp\e[0m\":\"myapp\",\"\e[32menv\e[0m\":\"prod\"}\n"].join)
19
+ end
20
+
21
+ it "changes default formatter to JSON as name" do
22
+ logger = TTY::Logger.new(output: output) do |config|
23
+ config.formatter = :json
24
+ end
25
+
26
+ logger.info("Logging", app: "myapp", env: "prod")
27
+
28
+ expect(output.string).to eq([
29
+ "\e[32m#{styles[:info][:symbol]}\e[0m ",
30
+ "\e[32minfo\e[0m ",
31
+ "Logging ",
32
+ "{\"\e[32mapp\e[0m\":\"myapp\",\"\e[32menv\e[0m\":\"prod\"}\n"].join)
33
+ end
34
+
35
+ it "changes default formatter for only one handler" do
36
+ logger = TTY::Logger.new(output: output) do |config|
37
+ config.handlers = [:console,
38
+ [:console, {formatter: :JSON}]]
39
+ end
40
+
41
+ logger.info("Logging", app: "myapp", env: "prod")
42
+
43
+ expect(output.string).to eq([
44
+ "\e[32m#{styles[:info][:symbol]}\e[0m ",
45
+ "\e[32minfo\e[0m ",
46
+ "Logging ",
47
+ "\e[32mapp\e[0m=myapp \e[32menv\e[0m=prod\n",
48
+ "\e[32m#{styles[:info][:symbol]}\e[0m ",
49
+ "\e[32minfo\e[0m ",
50
+ "Logging ",
51
+ "{\"\e[32mapp\e[0m\":\"myapp\",\"\e[32menv\e[0m\":\"prod\"}\n"].join)
52
+ end
53
+
54
+ it "fails to recognize formatter object type" do
55
+ expect {
56
+ TTY::Logger.new(output: output) do |config|
57
+ config.formatter = true
58
+ end
59
+ }.to raise_error(TTY::Logger::Error, "Unrecognized formatter name 'true'")
60
+ end
61
+
62
+ it "fails to recognize formatter name" do
63
+
64
+ expect {
65
+ TTY::Logger.new(output: output) do |config|
66
+ config.formatter = :unknown
67
+ end
68
+ }.to raise_error(TTY::Logger::Error, "Unrecognized formatter name ':unknown'")
69
+ end
70
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe TTY::Logger::Formatters::JSON, "#dump" do
4
+ it "dumps a log line" do
5
+ formatter = described_class.new
6
+ data = {
7
+ app: "myapp",
8
+ env: "prod",
9
+ sql: "SELECT * FROM admins",
10
+ at: Time.at(123456).utc
11
+ }
12
+
13
+ expect(formatter.dump(data)).to eq("{\"app\":\"myapp\",\"env\":\"prod\",\"sql\":\"SELECT * FROM admins\",\"at\":\"1970-01-02 10:17:36 UTC\"}")
14
+ end
15
+
16
+ [
17
+ {obj: {a: "aaaaa", b: "bbbbb", c: "ccccc"}, bytes: 3*12+2, want: "{\"a\":\"aaaaa\",\"b\":\"bbbbb\",\"c\":\"ccccc\"}"},
18
+ {obj: {a: "aaaaa", b: "bbbbb", c: "ccccc"}, bytes: 2*12+4, want: "{\"a\":\"aaaaa\",\"b\":\"bbbbb\",\"c\":\"...\"}"},
19
+ {obj: {a: "aaaaa", b: "bbbbb", c: "ccccc"}, bytes: 12+4, want: "{\"a\":\"aaaaa\",\"b\":\"...\"}"},
20
+ {obj: {a: "aaaaa", b: "bbbbb", c: "ccccc"}, bytes: 11, want: "{\"a\":\"...\"}"},
21
+ ].each do |data|
22
+ it "truncates #{data[:obj].inspect} to #{data[:want].inspect} of #{data[:bytes]} bytes" do
23
+ formatter = described_class.new
24
+ expect(formatter.dump(data[:obj], max_bytes: data[:bytes])).to eq(data[:want])
25
+ end
26
+ end
27
+
28
+ [
29
+ {obj: {a: {b: {c: "ccccc"}}}, depth: 1, want: "{\"a\":\"...\"}"},
30
+ {obj: {a: {b: {c: "ccccc"}}}, depth: 2, want: "{\"a\":{\"b\":\"...\"}}"},
31
+ {obj: {a: {b: {c: "ccccc"}}}, depth: 3, want: "{\"a\":{\"b\":{\"c\":\"ccccc\"}}}"},
32
+ {obj: {a: ["b", {c: "ccccc"}]}, depth: 1, want: "{\"a\":\"...\"}"},
33
+ {obj: {a: ["b", {c: "ccccc"}]}, depth: 2, want: "{\"a\":[\"b\",\"...\"]}"},
34
+ {obj: {a: ["b", {c: "ccccc"}]}, depth: 3, want: "{\"a\":[\"b\",{\"c\":\"ccccc\"}]}"},
35
+ ].each do |data|
36
+ it "truncates nested object #{data[:obj].inspect} to #{data[:want].inspect}" do
37
+ formatter = described_class.new
38
+ expect(formatter.dump(data[:obj], max_depth: data[:depth])).to eq(data[:want])
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe TTY::Logger::Formatters::Text, "#dump" do
4
+
5
+ [
6
+ {key: "k", value: "v", want: "k=v"},
7
+ {key: "k", value: '\n', want: "k=\\n"},
8
+ {key: "k", value: '\r', want: "k=\\r"},
9
+ {key: "k", value: '\t', want: "k=\\t"},
10
+ {key: "k", value: nil, want: "k=nil"},
11
+ {key: "k", value: "nil", want: "k=\"nil\""},
12
+ {key: "k", value: "", want: "k="},
13
+ {key: "k", value: true, want: "k=true"},
14
+ {key: "k", value: "true", want: "k=\"true\""},
15
+ {key: "k", value: "false", want: "k=\"false\""},
16
+ {key: "k", value: 1, want: "k=1"},
17
+ {key: "k", value: 1.035, want: "k=1.035"},
18
+ {key: "k", value: 1e-5, want: "k=0.00001"},
19
+ {key: "k", value: Complex(2,1), want: "k=(2+1i)"},
20
+ {key: "k", value: "1", want: "k=\"1\""},
21
+ {key: "k", value: "1.035", want: "k=\"1.035\""},
22
+ {key: "k", value: "1e-5", want: "k=\"1e-5\""},
23
+ {key: "k", value: "v v", want: "k=\"v v\""},
24
+ {key: "k", value: " ", want: 'k=" "'},
25
+ {key: "k", value: '"', want: 'k="\""'},
26
+ {key: "k", value: '=', want: 'k="="'},
27
+ {key: "k", value: "\\", want: "k=\\"},
28
+ {key: "k", value: "=\\", want: "k=\"=\\\\\""},
29
+ {key: "k", value: "\\\"", want: "k=\"\\\\\\\"\""},
30
+ {key: "", value: "", want: "="},
31
+ {key: '"', value: "v", want: '"\""=v'},
32
+ {key: "k", value: Time.new(2019, 7, 7, 12, 21, 35, "+02:00"), want: "k=2019-07-07T12:21:35+02:00"},
33
+ {key: "k", value: {a: 1}, want: "k={a=1}"},
34
+ {key: "k", value: {a: 1, b: 2}, want: "k={a=1 b=2}"},
35
+ {key: "k", value: {a: {b: 2}}, want: "k={a={b=2}}"},
36
+ {key: "k", value: ["a", 1], want: "k=[a 1]"},
37
+ {key: "k", value: ["a", ["b", 2], 1], want: "k=[a [b 2] 1]"},
38
+ ].each do |data|
39
+ it "dumps {#{data[:key].inspect} => #{data[:value].inspect}} as #{data[:want].inspect}" do
40
+ formatter = described_class.new
41
+ expect(formatter.dump({data[:key] => data[:value]})).to eq(data[:want])
42
+ end
43
+ end
44
+
45
+ [
46
+ {obj: {a: "aaaaa", b: "bbbbb", c: "ccccc"}, bytes: 24, want: "a=aaaaa b=bbbbb c=ccccc"},
47
+ {obj: {a: "aaaaa", b: "bbbbb", c: "ccccc"}, bytes: 20, want: "a=aaaaa b=bbbbb ..."},
48
+ {obj: {a: "aaaaa", b: "bbbbb", c: "ccccc"}, bytes: 15, want: "a=aaaaa ..."},
49
+ {obj: {a: "aaaaa", b: "bbbbb", c: "ccccc"}, bytes: 7, want: "..."},
50
+ ].each do |data|
51
+ it "truncates #{data[:obj].inspect} to #{data[:want].inspect} of #{data[:bytes]} bytes" do
52
+ formatter = described_class.new
53
+ expect(formatter.dump(data[:obj], max_bytes: data[:bytes])).to eq(data[:want])
54
+ end
55
+ end
56
+
57
+ [
58
+ {obj: {a: {b: {c: "ccccc"}}}, depth: 1, want: "a={...}"},
59
+ {obj: {a: {b: {c: "ccccc"}}}, depth: 2, want: "a={b={...}}"},
60
+ {obj: {a: {b: {c: "ccccc"}}}, depth: 3, want: "a={b={c=ccccc}}"},
61
+ {obj: {a: ["b", {c: "ccccc"}]}, depth: 1, want: "a=[...]"},
62
+ {obj: {a: ["b", {c: "ccccc"}]}, depth: 2, want: "a=[b {...}]"},
63
+ {obj: {a: ["b", {c: "ccccc"}]}, depth: 3, want: "a=[b {c=ccccc}]"},
64
+ ].each do |data|
65
+ it "truncates nested object #{data[:obj].inspect} to #{data[:want].inspect}" do
66
+ formatter = described_class.new
67
+ expect(formatter.dump(data[:obj], max_depth: data[:depth])).to eq(data[:want])
68
+ end
69
+ end
70
+
71
+ it "dumps a log line" do
72
+ formatter = described_class.new
73
+ data = {
74
+ app: "myapp",
75
+ env: "prod",
76
+ sql: "SELECT * FROM admins",
77
+ at: Time.at(123456).utc
78
+ }
79
+
80
+ expect(formatter.dump(data)).to eq("app=myapp env=prod sql=\"SELECT * FROM admins\" at=1970-01-02T10:17:36+00:00")
81
+ end
82
+ end