timber 1.0.13 → 1.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.
- checksums.yaml +4 -4
- data/README.md +278 -121
- data/lib/timber.rb +1 -0
- data/lib/timber/context.rb +1 -1
- data/lib/timber/contexts.rb +29 -2
- data/lib/timber/contexts/runtime.rb +24 -0
- data/lib/timber/contexts/system.rb +19 -0
- data/lib/timber/current_context.rb +14 -5
- data/lib/timber/event.rb +1 -1
- data/lib/timber/events.rb +11 -6
- data/lib/timber/events/controller_call.rb +1 -1
- data/lib/timber/events/custom.rb +1 -1
- data/lib/timber/events/exception.rb +1 -1
- data/lib/timber/events/{http_request.rb → http_server_request.rb} +1 -1
- data/lib/timber/events/{http_response.rb → http_server_response.rb} +2 -1
- data/lib/timber/events/sql_query.rb +2 -1
- data/lib/timber/events/template_render.rb +2 -1
- data/lib/timber/frameworks/rails.rb +12 -1
- data/lib/timber/log_devices/http.rb +29 -24
- data/lib/timber/log_entry.rb +23 -9
- data/lib/timber/logger.rb +20 -6
- data/lib/timber/probes.rb +1 -3
- data/lib/timber/probes/active_support_tagged_logging.rb +0 -43
- data/lib/timber/probes/rails_rack_logger.rb +1 -1
- data/lib/timber/rack_middlewares.rb +12 -0
- data/lib/timber/rack_middlewares/http_context.rb +30 -0
- data/lib/timber/util.rb +1 -0
- data/lib/timber/util/struct.rb +16 -0
- data/lib/timber/version.rb +1 -1
- data/spec/README.md +23 -0
- data/spec/support/timber.rb +1 -1
- data/spec/timber/contexts_spec.rb +49 -0
- data/spec/timber/events_spec.rb +1 -1
- data/spec/timber/log_devices/http_spec.rb +7 -7
- data/spec/timber/log_entry_spec.rb +15 -0
- data/spec/timber/logger_spec.rb +14 -10
- data/spec/timber/probes/action_controller_log_subscriber_spec.rb +6 -7
- data/spec/timber/probes/action_dispatch_debug_exceptions_spec.rb +1 -1
- data/spec/timber/probes/action_view_log_subscriber_spec.rb +2 -2
- data/spec/timber/probes/rails_rack_logger_spec.rb +3 -3
- data/spec/timber/rack_middlewares/http_context_spec.rb +47 -0
- data/timber.gemspec +1 -0
- metadata +31 -8
- data/lib/timber/contexts/tags.rb +0 -22
- data/lib/timber/probes/rack_http_context.rb +0 -51
- data/spec/timber/probes/rack_http_context_spec.rb +0 -50
data/lib/timber.rb
CHANGED
data/lib/timber/context.rb
CHANGED
data/lib/timber/contexts.rb
CHANGED
@@ -1,11 +1,38 @@
|
|
1
1
|
require "timber/contexts/custom"
|
2
2
|
require "timber/contexts/http"
|
3
3
|
require "timber/contexts/organization"
|
4
|
-
require "timber/contexts/
|
4
|
+
require "timber/contexts/runtime"
|
5
|
+
require "timber/contexts/system"
|
5
6
|
require "timber/contexts/user"
|
6
7
|
|
7
8
|
module Timber
|
8
|
-
#
|
9
|
+
# Namespace for all Timber supported Contexts.
|
9
10
|
module Contexts
|
11
|
+
# Protocol for casting objects into a `Timber::Context`.
|
12
|
+
#
|
13
|
+
# @example Casting a hash
|
14
|
+
# Timber::Contexts.build(deploy: {version: "1.0.0"})
|
15
|
+
def self.build(obj)
|
16
|
+
if obj.is_a?(::Timber::Context)
|
17
|
+
obj
|
18
|
+
elsif obj.respond_to?(:to_timber_context)
|
19
|
+
obj.to_timber_context
|
20
|
+
elsif obj.is_a?(Hash) && obj.length == 1
|
21
|
+
type = obj.keys.first
|
22
|
+
data = obj.values.first
|
23
|
+
|
24
|
+
Contexts::Custom.new(
|
25
|
+
type: type,
|
26
|
+
data: data
|
27
|
+
)
|
28
|
+
elsif obj.is_a?(Struct) && obj.respond_to?(:type)
|
29
|
+
Contexts::Custom.new(
|
30
|
+
type: obj.type,
|
31
|
+
data: obj.respond_to?(:to_h) ? obj.to_h : Timber::Util::Struct.to_hash(obj) # ruby 1.9.3 does not have to_h
|
32
|
+
)
|
33
|
+
else
|
34
|
+
nil
|
35
|
+
end
|
36
|
+
end
|
10
37
|
end
|
11
38
|
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Timber
|
2
|
+
module Contexts
|
3
|
+
# Tracks OS level process information, such as the process ID.
|
4
|
+
class Runtime < Context
|
5
|
+
@keyspace = :runtime
|
6
|
+
|
7
|
+
attr_reader :application, :class_name, :file, :function, :line, :module_name
|
8
|
+
|
9
|
+
def initialize(attributes)
|
10
|
+
@application = attributes[:application]
|
11
|
+
@class_name = attributes[:class_name]
|
12
|
+
@file = attributes[:file]
|
13
|
+
@function = attributes[:function]
|
14
|
+
@line = attributes[:line]
|
15
|
+
@module_name = attributes[:module_name]
|
16
|
+
end
|
17
|
+
|
18
|
+
def as_json(_options = {})
|
19
|
+
{application: application, class_name: class_name, file: file, function: function,
|
20
|
+
line: line, module_name: module_name}
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Timber
|
2
|
+
module Contexts
|
3
|
+
# Tracks OS level process information, such as the process ID.
|
4
|
+
class System < Context
|
5
|
+
@keyspace = :system
|
6
|
+
|
7
|
+
attr_reader :pid
|
8
|
+
|
9
|
+
def initialize(attributes)
|
10
|
+
@pid = attributes[:pid] || raise(ArgumentError.new(":pid is required"))
|
11
|
+
@pid = @pid.to_s
|
12
|
+
end
|
13
|
+
|
14
|
+
def as_json(_options = {})
|
15
|
+
{pid: pid}
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -38,9 +38,17 @@ module Timber
|
|
38
38
|
# @note Because context is included with every log line, it is recommended that you limit this
|
39
39
|
# to only neccessary data.
|
40
40
|
#
|
41
|
-
# @example Adding a custom context
|
42
|
-
#
|
43
|
-
#
|
41
|
+
# @example Adding a custom context with a map
|
42
|
+
# Timber::CurrentContext.with({build: {version: "1.0.0"}}) do
|
43
|
+
# # ... anything logged here will include the context ...
|
44
|
+
# end
|
45
|
+
#
|
46
|
+
# @example Adding a custom context with a struct
|
47
|
+
# BuildContext = Struct.new(:version) do
|
48
|
+
# def type; :build; end
|
49
|
+
# end
|
50
|
+
# build_context = BuildContext.new("1.0.0")
|
51
|
+
# Timber::CurrentContext.with(build_context) do
|
44
52
|
# # ... anything logged here will include the context ...
|
45
53
|
# end
|
46
54
|
# # Be sure to checkout Timber::Contexts! These are officially supported and many of these
|
@@ -67,8 +75,9 @@ module Timber
|
|
67
75
|
#
|
68
76
|
# @note Because context is included with every log line, it is recommended that you limit this
|
69
77
|
# to only neccessary data.
|
70
|
-
def add(*
|
71
|
-
|
78
|
+
def add(*objects)
|
79
|
+
objects.each do |object|
|
80
|
+
context = Contexts.build(object) # Normalizes objects into a Timber::Context descendant.
|
72
81
|
key = context.keyspace
|
73
82
|
json = context.as_json # Convert to json now so that we aren't doing it for every line
|
74
83
|
if key == :custom
|
data/lib/timber/event.rb
CHANGED
data/lib/timber/events.rb
CHANGED
@@ -1,12 +1,13 @@
|
|
1
1
|
require "timber/events/controller_call"
|
2
2
|
require "timber/events/custom"
|
3
3
|
require "timber/events/exception"
|
4
|
-
require "timber/events/
|
5
|
-
require "timber/events/
|
4
|
+
require "timber/events/http_server_request"
|
5
|
+
require "timber/events/http_server_response"
|
6
6
|
require "timber/events/sql_query"
|
7
7
|
require "timber/events/template_render"
|
8
8
|
|
9
9
|
module Timber
|
10
|
+
# Namespace for all Timber supported events.
|
10
11
|
module Events
|
11
12
|
# Protocol for casting objects into a `Timber::Event`.
|
12
13
|
#
|
@@ -17,17 +18,21 @@ module Timber
|
|
17
18
|
obj
|
18
19
|
elsif obj.respond_to?(:to_timber_event)
|
19
20
|
obj.to_timber_event
|
20
|
-
elsif obj.is_a?(Hash) && obj.key?(:message) && obj.
|
21
|
+
elsif obj.is_a?(Hash) && obj.key?(:message) && obj.length == 2
|
22
|
+
event = obj.select { |k,v| k != :message }
|
23
|
+
type = event.keys.first
|
24
|
+
data = event.values.first
|
25
|
+
|
21
26
|
Events::Custom.new(
|
22
|
-
type:
|
27
|
+
type: type,
|
23
28
|
message: obj[:message],
|
24
|
-
data:
|
29
|
+
data: data
|
25
30
|
)
|
26
31
|
elsif obj.is_a?(Struct) && obj.respond_to?(:message) && obj.respond_to?(:type)
|
27
32
|
Events::Custom.new(
|
28
33
|
type: obj.type,
|
29
34
|
message: obj.message,
|
30
|
-
data: obj.respond_to?(:
|
35
|
+
data: obj.respond_to?(:to_h) ? obj.to_h : Timber::Util::Struct.to_hash(obj) # ruby 1.9.3 does not have to_h :(
|
31
36
|
)
|
32
37
|
else
|
33
38
|
nil
|
data/lib/timber/events/custom.rb
CHANGED
@@ -10,6 +10,7 @@ module Timber
|
|
10
10
|
def initialize(attributes)
|
11
11
|
@status = attributes[:status] || raise(ArgumentError.new(":status is required"))
|
12
12
|
@time_ms = attributes[:time_ms] || raise(ArgumentError.new(":time_ms is required"))
|
13
|
+
@time_ms = @time_ms.round(6)
|
13
14
|
@additions = attributes[:additions]
|
14
15
|
end
|
15
16
|
|
@@ -19,7 +20,7 @@ module Timber
|
|
19
20
|
alias to_h to_hash
|
20
21
|
|
21
22
|
def as_json(_options = {})
|
22
|
-
{:
|
23
|
+
{:server_side_app => {:http_server_response => to_hash}}
|
23
24
|
end
|
24
25
|
|
25
26
|
def message
|
@@ -10,6 +10,7 @@ module Timber
|
|
10
10
|
def initialize(attributes)
|
11
11
|
@sql = attributes[:sql] || raise(ArgumentError.new(":sql is required"))
|
12
12
|
@time_ms = attributes[:time_ms] || raise(ArgumentError.new(":time_ms is required"))
|
13
|
+
@time_ms = @time_ms.round(6)
|
13
14
|
@message = attributes[:message] || raise(ArgumentError.new(":message is required"))
|
14
15
|
end
|
15
16
|
|
@@ -19,7 +20,7 @@ module Timber
|
|
19
20
|
alias to_h to_hash
|
20
21
|
|
21
22
|
def as_json(_options = {})
|
22
|
-
{:sql_query => to_hash}
|
23
|
+
{:server_side_app => {:sql_query => to_hash}}
|
23
24
|
end
|
24
25
|
end
|
25
26
|
end
|
@@ -11,6 +11,7 @@ module Timber
|
|
11
11
|
@message = attributes[:message] || raise(ArgumentError.new(":message is required"))
|
12
12
|
@name = attributes[:name] || raise(ArgumentError.new(":name is required"))
|
13
13
|
@time_ms = attributes[:time_ms] || raise(ArgumentError.new(":time_ms is required"))
|
14
|
+
@time_ms = @time_ms.round(6)
|
14
15
|
end
|
15
16
|
|
16
17
|
def to_hash
|
@@ -19,7 +20,7 @@ module Timber
|
|
19
20
|
alias to_h to_hash
|
20
21
|
|
21
22
|
def as_json(_options = {})
|
22
|
-
{:template_render => to_hash}
|
23
|
+
{:server_side_app => {:template_render => to_hash}}
|
23
24
|
end
|
24
25
|
end
|
25
26
|
end
|
@@ -5,7 +5,18 @@ module Timber
|
|
5
5
|
class Railtie < ::Rails::Railtie
|
6
6
|
config.timber = Config.instance
|
7
7
|
config.before_initialize do
|
8
|
-
Probes.insert!
|
8
|
+
Probes.insert!
|
9
|
+
Timber::Frameworks::Rails.insert_middlewares(config.app_middleware)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.insert_middlewares(middleware)
|
14
|
+
var_name = :"@_timber_middlewares_inserted"
|
15
|
+
return true if middleware.instance_variable_defined?(var_name) && middleware.instance_variable_get(var_name) == true
|
16
|
+
# Rails uses a proxy :/, so we need to do this instance variable hack
|
17
|
+
middleware.instance_variable_set(var_name, true)
|
18
|
+
Timber::RackMiddlewares.middlewares.each do |m|
|
19
|
+
middleware.insert_before ::Rails::Rack::Logger, m
|
9
20
|
end
|
10
21
|
end
|
11
22
|
end
|
@@ -1,7 +1,7 @@
|
|
1
1
|
module Timber
|
2
2
|
module LogDevices
|
3
|
-
# A log device that buffers and delivers log messages over HTTPS to
|
4
|
-
# It uses batches, keep-alive connections, and
|
3
|
+
# A highly efficient log device that buffers and delivers log messages over HTTPS to
|
4
|
+
# the Timber API. It uses batches, keep-alive connections, and msgpack to deliver logs with
|
5
5
|
# high-throughput and little overhead.
|
6
6
|
#
|
7
7
|
# See {#initialize} for options and more details.
|
@@ -29,9 +29,7 @@ module Timber
|
|
29
29
|
end
|
30
30
|
|
31
31
|
def full?
|
32
|
-
@
|
33
|
-
size >= @max_size
|
34
|
-
end
|
32
|
+
size >= @max_size
|
35
33
|
end
|
36
34
|
|
37
35
|
def size
|
@@ -61,11 +59,9 @@ module Timber
|
|
61
59
|
end
|
62
60
|
|
63
61
|
TIMBER_URL = "https://logs.timber.io/frames".freeze
|
62
|
+
ACCEPT = "application/json".freeze
|
64
63
|
CONTENT_TYPE = "application/msgpack".freeze
|
65
|
-
USER_AGENT = "Timber Ruby
|
66
|
-
DELIVERY_FREQUENCY_SECONDS = 2.freeze
|
67
|
-
RETRY_LIMIT = 5.freeze
|
68
|
-
BACKOFF_RATE_SECONDS = 3.freeze
|
64
|
+
USER_AGENT = "Timber Ruby/#{Timber::VERSION} (HTTP)".freeze
|
69
65
|
|
70
66
|
|
71
67
|
# Instantiates a new HTTP log device that can be passed to {Timber::Logger#initialize}.
|
@@ -81,14 +77,17 @@ module Timber
|
|
81
77
|
# @param api_key [String] The API key provided to you after you add your application to
|
82
78
|
# [Timber](https://timber.io).
|
83
79
|
# @param [Hash] options the options to create a HTTP log device with.
|
84
|
-
# @option attributes [Symbol] :batch_size (
|
85
|
-
# payload. If the queue exceeds this limit
|
80
|
+
# @option attributes [Symbol] :batch_size (1000) Determines the maximum of log lines in
|
81
|
+
# each HTTP payload. If the queue exceeds this limit an HTTP request will be issued. Bigger
|
82
|
+
# payloads mean higher throughput, but also use more memory. Timber will not accept
|
83
|
+
# payloads larger than 1mb.
|
86
84
|
# @option attributes [Symbol] :debug (false) Whether to print debug output or not. This is also
|
87
85
|
# inferred from ENV['debug']. Output will be sent to `Timber::Config.logger`.
|
88
|
-
# @option attributes [Symbol] :flush_interval (
|
89
|
-
# attempt to deliver logs to the Timber API. The HTTP client buffers
|
90
|
-
# options represents how often that will happen, assuming `:batch_byte_size`
|
91
|
-
#
|
86
|
+
# @option attributes [Symbol] :flush_interval (1) How often the client should
|
87
|
+
# attempt to deliver logs to the Timber API in fractional seconds. The HTTP client buffers
|
88
|
+
# logs and this options represents how often that will happen, assuming `:batch_byte_size`
|
89
|
+
# is not met.
|
90
|
+
# @option attributes [Symbol] :requests_per_conn (2500) The number of requests to send over a
|
92
91
|
# single persistent connection. After this number is met, the connection will be closed
|
93
92
|
# and a new one will be opened.
|
94
93
|
# @option attributes [Symbol] :request_queue (SizedQueue.new(3)) The request queue object that queues Net::HTTP
|
@@ -110,9 +109,9 @@ module Timber
|
|
110
109
|
@api_key = api_key
|
111
110
|
@debug = options[:debug] || ENV['debug']
|
112
111
|
@timber_url = URI.parse(options[:timber_url] || ENV['TIMBER_URL'] || TIMBER_URL)
|
113
|
-
@batch_size = options[:batch_size] ||
|
114
|
-
@flush_interval = options[:flush_interval] ||
|
115
|
-
@requests_per_conn = options[:requests_per_conn] ||
|
112
|
+
@batch_size = options[:batch_size] || 1_000
|
113
|
+
@flush_interval = options[:flush_interval] || 1 # 1 second
|
114
|
+
@requests_per_conn = options[:requests_per_conn] || 2_500
|
116
115
|
@msg_queue = LogMsgQueue.new(@batch_size)
|
117
116
|
@request_queue = options[:request_queue] || SizedQueue.new(3)
|
118
117
|
@requests_in_flight = 0
|
@@ -128,6 +127,7 @@ module Timber
|
|
128
127
|
def write(msg)
|
129
128
|
@msg_queue.enqueue(msg)
|
130
129
|
if @msg_queue.full?
|
130
|
+
logger.debug("Flushing timber buffer via write") if debug?
|
131
131
|
flush
|
132
132
|
end
|
133
133
|
true
|
@@ -146,17 +146,17 @@ module Timber
|
|
146
146
|
end
|
147
147
|
|
148
148
|
def flush
|
149
|
+
@last_flush = Time.now
|
149
150
|
msgs = @msg_queue.flush
|
150
151
|
return if msgs.empty?
|
151
152
|
|
152
153
|
req = Net::HTTP::Post.new(@timber_url.path)
|
153
|
-
req['Accept'] =
|
154
|
+
req['Accept'] = ACCEPT
|
154
155
|
req['Authorization'] = authorization_payload
|
155
156
|
req['Content-Type'] = CONTENT_TYPE
|
156
157
|
req['User-Agent'] = USER_AGENT
|
157
158
|
req.body = msgs.to_msgpack
|
158
159
|
@request_queue.enq(req)
|
159
|
-
@last_flush = Time.now
|
160
160
|
end
|
161
161
|
|
162
162
|
def intervaled_flush
|
@@ -165,11 +165,12 @@ module Timber
|
|
165
165
|
loop do
|
166
166
|
begin
|
167
167
|
if intervaled_flush_ready?
|
168
|
+
logger.debug("Flushing timber buffer via the interval") if debug?
|
168
169
|
flush
|
169
170
|
end
|
170
171
|
sleep(0.1)
|
171
172
|
rescue Exception => e
|
172
|
-
logger.error("Timber intervaled flush failed: #{e.inspect}")
|
173
|
+
logger.error("Timber intervaled flush failed: #{e.inspect}\n\n#{e.backtrace}")
|
173
174
|
end
|
174
175
|
end
|
175
176
|
end
|
@@ -192,8 +193,12 @@ module Timber
|
|
192
193
|
http.start do |conn|
|
193
194
|
num_reqs = 0
|
194
195
|
while num_reqs < @requests_per_conn
|
196
|
+
if debug?
|
197
|
+
logger.debug("Waiting on next Timber request")
|
198
|
+
logger.debug("Number of threads waiting on Timber request queue: #{@request_queue.num_waiting}")
|
199
|
+
end
|
200
|
+
|
195
201
|
# Blocks waiting for a request.
|
196
|
-
logger.info("Waiting on next Timber request") if debug?
|
197
202
|
req = @request_queue.deq
|
198
203
|
@requests_in_flight += 1
|
199
204
|
resp = nil
|
@@ -206,13 +211,13 @@ module Timber
|
|
206
211
|
@requests_in_flight -= 1
|
207
212
|
end
|
208
213
|
num_reqs += 1
|
209
|
-
logger.
|
214
|
+
logger.debug("Timber request successful: #{resp.code}") if debug?
|
210
215
|
end
|
211
216
|
end
|
212
217
|
rescue => e
|
213
218
|
logger.error("Timber request error: #{e.message}") if debug?
|
214
219
|
ensure
|
215
|
-
logger.
|
220
|
+
logger.debug("Finishing Timber HTTP connection") if debug?
|
216
221
|
http.finish if http.started?
|
217
222
|
end
|
218
223
|
end
|
data/lib/timber/log_entry.rb
CHANGED
@@ -3,38 +3,50 @@ module Timber
|
|
3
3
|
# `Logger` and the log device that you set it up with.
|
4
4
|
class LogEntry #:nodoc:
|
5
5
|
DT_PRECISION = 6.freeze
|
6
|
+
SCHEMA = "https://raw.githubusercontent.com/timberio/log-event-json-schema/1.2.3/schema.json".freeze
|
6
7
|
|
7
|
-
attr_reader :
|
8
|
+
attr_reader :context_snapshot, :event, :level, :message, :progname, :tags, :time
|
8
9
|
|
9
10
|
# Creates a log entry suitable to be sent to the Timber API.
|
10
11
|
# @param severity [Integer] the log level / severity
|
11
12
|
# @param time [Time] the exact time the log message was written
|
12
13
|
# @param progname [String] the progname scope for the log message
|
13
|
-
# @param message [
|
14
|
-
#
|
14
|
+
# @param message [String] Human readable log message.
|
15
|
+
# @param context_snapshot [Hash] structured data representing a snapshot of the context at
|
16
|
+
# the given point in time.
|
17
|
+
# @param event [Timber.Event] structured data representing the log line event. This should be
|
18
|
+
# an instance of `Timber.Event`.
|
15
19
|
# @return [LogEntry] the resulting LogEntry object
|
16
|
-
def initialize(level, time, progname, message,
|
20
|
+
def initialize(level, time, progname, message, context_snapshot, event, tags)
|
17
21
|
@level = level
|
18
22
|
@time = time.utc
|
19
23
|
@progname = progname
|
20
24
|
@message = message
|
21
|
-
@
|
25
|
+
@tags = tags
|
26
|
+
|
27
|
+
context_snapshot = {} if context_snapshot.nil?
|
28
|
+
system_context = Contexts::System.new(pid: Process.pid)
|
29
|
+
context_snapshot[system_context.keyspace] = system_context.as_json
|
30
|
+
|
31
|
+
@context_snapshot = context_snapshot
|
22
32
|
@event = event
|
23
33
|
end
|
24
34
|
|
25
35
|
def as_json(options = {})
|
26
36
|
options ||= {}
|
27
|
-
hash = {level
|
37
|
+
hash = {:level => level, :dt => formatted_dt, :message => message, :tags => tags}
|
28
38
|
|
29
39
|
if !event.nil?
|
30
40
|
hash[:event] = event
|
31
41
|
end
|
32
42
|
|
33
|
-
if !
|
34
|
-
hash[:context] =
|
43
|
+
if !context_snapshot.nil? && context_snapshot.length > 0
|
44
|
+
hash[:context] = context_snapshot
|
35
45
|
end
|
36
46
|
|
37
|
-
|
47
|
+
hash[:"$schema"] = SCHEMA
|
48
|
+
|
49
|
+
hash = if options[:only]
|
38
50
|
hash.select do |key, _value|
|
39
51
|
options[:only].include?(key)
|
40
52
|
end
|
@@ -45,6 +57,8 @@ module Timber
|
|
45
57
|
else
|
46
58
|
hash
|
47
59
|
end
|
60
|
+
|
61
|
+
Util::Hash.compact(hash)
|
48
62
|
end
|
49
63
|
|
50
64
|
def to_json(options = {})
|