tty-logger 0.0.0 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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