philiprehberger-structured_logger 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ef7c59f106a991921ebecf414e00246f465120af2cc8d1abf17ebc9dafeb227
4
- data.tar.gz: 6b803b7b323343ba481445416dabffeed056d6a79788ada1c797b752c88dcb87
3
+ metadata.gz: aa759b5a2e0251937f0ab9951c533f233ef8129a4b13c724995bf98699618374
4
+ data.tar.gz: 5908a03340aaf26b1b0a428d7756874f6fc6caabe14a137fad5fec8e7e3110a9
5
5
  SHA512:
6
- metadata.gz: 7c14cb179e55002dc57fdaf65fb59dc8d570da2da2fb62205aed82bd49e916704409ab46f0350f690e57d02478591cb256ce49f7fb057655e9894ae8c842e257
7
- data.tar.gz: caef71a9ca0b7d278d77eb2f0a31b7c0349af95905f143535d008a7f3e95f37315c393ca183f383844a05819c7be7c487d98b4c8cf44357b6e237e6ad00820cd
6
+ metadata.gz: dadadcbd9611ee77a72099b12a2441c83405fc1d2a11a74d841fa09b7ab29ee0201a7c9743130378809d7e1e7ad92c7c55244e35a36fabe1cbfba6c5e1c49e82
7
+ data.tar.gz: 14f5d6583b9713eac95fc8d1a564de17e78ee665789766fcc3662dd7154ec852d12616fae740ad3bea6ce50079e78757d7ebd43af5cdccbb3a2ba38144715fd4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Add multiple outputs (appenders) with per-output level filtering and formatters
6
+ - Add `add_output` for adding output destinations at runtime
7
+ - Add custom formatters: `:json` (default), `:text`, and any callable (proc/lambda)
8
+ - Add `TextFormatter` for human-readable `[TIMESTAMP] LEVEL: message key=value` output
9
+ - Add log sampling with configurable rates per level
10
+ - Add `with_correlation_id` for injecting correlation/request IDs via Thread-local storage
11
+ - Add buffered async output via background thread with backpressure support
12
+ - Add `flush` and `close` methods for async writer lifecycle management
13
+
14
+ ## 0.2.1
15
+
16
+ - Add License badge to README
17
+ - Add bug_tracker_uri to gemspec
18
+ - Add Development section to README
19
+ - Add Requirements section to README
20
+
21
+ ## [0.2.0] - 2026-03-13
22
+ - Add `level` getter to read the current log level
23
+ - Add `with_context` for temporarily merging context during a block
24
+ - Add `silence` for temporarily raising the log level during a block
25
+ - Add `log_exception` for logging exception class, message, and backtrace
26
+
3
27
  ## [0.1.0] - 2026-03-10
4
28
  - Initial release
5
29
  - Structured JSON log output
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Philiprehberger::StructuredLogger
2
2
 
3
- A zero-dependency Ruby gem for structured JSON logging with context merging, child loggers, level filtering, and pluggable outputs.
3
+ A zero-dependency Ruby gem for structured JSON logging with context merging, child loggers, level filtering, multiple outputs, custom formatters, log sampling, correlation IDs, and async output.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby >= 3.1
4
8
 
5
9
  ## Installation
6
10
 
@@ -61,35 +65,198 @@ logger.warn("visible") # written
61
65
  logger.error("visible") # written
62
66
  ```
63
67
 
68
+ Read the current level:
69
+
70
+ ```ruby
71
+ logger.level # => :debug
72
+ ```
73
+
64
74
  Change the level at runtime:
65
75
 
66
76
  ```ruby
67
77
  logger.level = :error
68
78
  ```
69
79
 
70
- ### Custom Output
80
+ ### Temporary Context
81
+
82
+ Use `with_context` to add context for the duration of a block:
83
+
84
+ ```ruby
85
+ logger.with_context(request_id: "abc-123") do
86
+ logger.info("Processing request")
87
+ # => {"timestamp":"...","level":"info","message":"Processing request","request_id":"abc-123"}
88
+ end
89
+ # Context is restored after the block
90
+ ```
91
+
92
+ ### Silence
71
93
 
72
- Pass any IO-like object that responds to `puts`:
94
+ Temporarily suppress log output by raising the minimum level:
73
95
 
74
96
  ```ruby
75
- file = File.open("app.log", "a")
76
- logger = Philiprehberger::StructuredLogger::Logger.new(output: file)
97
+ logger.silence(:fatal) do
98
+ logger.info("suppressed") # not written
99
+ logger.error("suppressed") # not written
100
+ end
101
+ # Level is restored after the block
77
102
  ```
78
103
 
104
+ ### Exception Logging
105
+
106
+ Log exceptions with class, message, and backtrace:
107
+
108
+ ```ruby
109
+ begin
110
+ risky_operation
111
+ rescue => e
112
+ logger.log_exception(e)
113
+ # => {"timestamp":"...","level":"error","message":"something broke","error_class":"RuntimeError","backtrace":[...]}
114
+ end
115
+
116
+ # Custom level and extra context:
117
+ logger.log_exception(e, level: :fatal, user_id: 42)
118
+ ```
119
+
120
+ ### Multiple Outputs
121
+
122
+ Log to multiple destinations simultaneously. Each output can have its own level filter and formatter:
123
+
124
+ ```ruby
125
+ logger = Philiprehberger::StructuredLogger::Logger.new(
126
+ outputs: [$stdout, File.open("app.log", "a")]
127
+ )
128
+
129
+ # With per-output configuration:
130
+ logger = Philiprehberger::StructuredLogger::Logger.new(outputs: [
131
+ { io: $stdout, formatter: :text },
132
+ { io: File.open("app.log", "a"), formatter: :json },
133
+ { io: $stderr, level: :error }
134
+ ])
135
+ ```
136
+
137
+ Add outputs at runtime:
138
+
139
+ ```ruby
140
+ logger.add_output($stderr, level: :error)
141
+ logger.add_output(File.open("debug.log", "a"), formatter: :text)
142
+ ```
143
+
144
+ The singular `output:` parameter still works for backwards compatibility:
145
+
146
+ ```ruby
147
+ logger = Philiprehberger::StructuredLogger::Logger.new(output: $stdout)
148
+ ```
149
+
150
+ ### Custom Formatters
151
+
152
+ Choose from built-in formatters or provide your own:
153
+
154
+ ```ruby
155
+ # JSON formatter (default)
156
+ logger = Philiprehberger::StructuredLogger::Logger.new(formatter: :json)
157
+
158
+ # Text formatter — human-readable output
159
+ logger = Philiprehberger::StructuredLogger::Logger.new(formatter: :text)
160
+ logger.info("hello", user: "alice")
161
+ # => [2026-03-10T12:00:00.000Z] INFO: hello user=alice
162
+
163
+ # Custom proc formatter
164
+ logger = Philiprehberger::StructuredLogger::Logger.new(
165
+ formatter: ->(level, message, context) { "#{level.upcase} #{message}" }
166
+ )
167
+
168
+ # Any callable object
169
+ class MyFormatter
170
+ def call(level, message, context)
171
+ "#{level}|#{message}|#{context.to_json}"
172
+ end
173
+ end
174
+
175
+ logger = Philiprehberger::StructuredLogger::Logger.new(formatter: MyFormatter.new)
176
+ ```
177
+
178
+ ### Log Sampling
179
+
180
+ Sample a percentage of logs per level to reduce volume:
181
+
182
+ ```ruby
183
+ logger = Philiprehberger::StructuredLogger::Logger.new(
184
+ sampling: { debug: 0.1, info: 0.5 }
185
+ )
186
+ ```
187
+
188
+ - `1.0` means log everything (default for unspecified levels)
189
+ - `0.5` means log approximately 50%
190
+ - `0.0` means log nothing
191
+
192
+ ### Correlation ID
193
+
194
+ Inject a correlation/request ID into all log entries within a block:
195
+
196
+ ```ruby
197
+ logger.with_correlation_id("req-abc-123") do
198
+ logger.info("processing")
199
+ # => {"timestamp":"...","level":"info","message":"processing","correlation_id":"req-abc-123"}
200
+ end
201
+
202
+ # Auto-generate a UUID:
203
+ logger.with_correlation_id do
204
+ logger.info("processing")
205
+ # => {"timestamp":"...","level":"info","message":"processing","correlation_id":"550e8400-e29b-41d4-a716-446655440000"}
206
+ end
207
+ ```
208
+
209
+ Correlation IDs nest correctly and are stored in Thread-local storage:
210
+
211
+ ```ruby
212
+ logger.with_correlation_id("outer") do
213
+ logger.with_correlation_id("inner") do
214
+ logger.info("nested") # correlation_id: "inner"
215
+ end
216
+ logger.info("back") # correlation_id: "outer"
217
+ end
218
+ ```
219
+
220
+ ### Async Output
221
+
222
+ Enable non-blocking log writes via a background thread:
223
+
224
+ ```ruby
225
+ logger = Philiprehberger::StructuredLogger::Logger.new(async: true, buffer_size: 100)
226
+
227
+ logger.info("non-blocking")
228
+
229
+ # Force immediate write of buffered entries:
230
+ logger.flush
231
+
232
+ # Flush and stop the background thread:
233
+ logger.close
234
+ ```
235
+
236
+ When the buffer is full, writes fall back to synchronous mode (backpressure) to avoid dropping log entries.
237
+
79
238
  ## API
80
239
 
81
240
  ### `Philiprehberger::StructuredLogger::Logger`
82
241
 
83
242
  | Method | Description |
84
243
  |---|---|
85
- | `new(output: $stdout, level: :debug, context: {})` | Create a logger |
244
+ | `new(output: $stdout, outputs: nil, level: :debug, context: {}, formatter: nil, sampling: {}, async: false, buffer_size: 1000)` | Create a logger |
86
245
  | `debug(message, **extra)` | Log at debug level |
87
246
  | `info(message, **extra)` | Log at info level |
88
247
  | `warn(message, **extra)` | Log at warn level |
89
248
  | `error(message, **extra)` | Log at error level |
90
249
  | `fatal(message, **extra)` | Log at fatal level |
91
250
  | `child(**context)` | Create a child logger with merged context |
251
+ | `level` | Get the current log level |
92
252
  | `level=(new_level)` | Set the minimum log level |
253
+ | `with_context(**extra, &block)` | Temporarily merge context for a block |
254
+ | `silence(level = :fatal, &block)` | Temporarily raise log level for a block |
255
+ | `log_exception(exception, level: :error, **extra)` | Log exception details |
256
+ | `add_output(io, level: nil, formatter: nil)` | Add an output destination at runtime |
257
+ | `with_correlation_id(id = nil, &block)` | Set a correlation ID for the block |
258
+ | `flush` | Force write of all buffered log entries |
259
+ | `close` | Flush and stop async background threads |
93
260
 
94
261
  ### `Philiprehberger::StructuredLogger::Formatter`
95
262
 
@@ -97,6 +264,29 @@ logger = Philiprehberger::StructuredLogger::Logger.new(output: file)
97
264
  |---|---|
98
265
  | `call(level, message, context)` | Build a JSON log string |
99
266
 
267
+ ### `Philiprehberger::StructuredLogger::TextFormatter`
268
+
269
+ | Method | Description |
270
+ |---|---|
271
+ | `call(level, message, context)` | Build a human-readable text log string |
272
+
273
+ ### `Philiprehberger::StructuredLogger::AsyncWriter`
274
+
275
+ | Method | Description |
276
+ |---|---|
277
+ | `new(output, buffer_size: 1000)` | Create an async writer wrapping an IO |
278
+ | `puts(line)` | Enqueue a line for async writing |
279
+ | `flush` | Force write of buffered entries |
280
+ | `close` | Flush and stop the background thread |
281
+
282
+ ## Development
283
+
284
+ ```bash
285
+ bundle install
286
+ bundle exec rspec
287
+ bundle exec rubocop
288
+ ```
289
+
100
290
  ## License
101
291
 
102
292
  MIT
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module StructuredLogger
5
+ # Non-blocking log writer that enqueues log lines to a background thread.
6
+ # Falls back to synchronous writes when the buffer is full (backpressure).
7
+ class AsyncWriter
8
+ # @param output [IO] writable output destination
9
+ # @param buffer_size [Integer] maximum number of queued log lines
10
+ def initialize(output, buffer_size: 1000)
11
+ @output = output
12
+ @buffer_size = buffer_size
13
+ @queue = SizedQueue.new(buffer_size)
14
+ @stopped = false
15
+ @closed = false
16
+ @mutex = Mutex.new
17
+ @thread = Thread.new { drain }
18
+ end
19
+
20
+ # Enqueue a log line for asynchronous writing.
21
+ # Falls back to synchronous write if the buffer is full.
22
+ #
23
+ # @param line [String] the formatted log line
24
+ def write(line)
25
+ @mutex.synchronize do
26
+ return sync_write(line) if @stopped
27
+ end
28
+
29
+ begin
30
+ @queue.push(line, true)
31
+ rescue ThreadError
32
+ sync_write(line)
33
+ end
34
+ end
35
+
36
+ # Write a log line using puts.
37
+ #
38
+ # @param line [String] the formatted log line
39
+ def puts(line)
40
+ write(line)
41
+ end
42
+
43
+ # Force all buffered log lines to be written immediately.
44
+ def flush
45
+ Thread.pass until @queue.empty?
46
+ @output.flush if @output.respond_to?(:flush)
47
+ end
48
+
49
+ # Flush remaining log lines and stop the background thread.
50
+ # Safe to call multiple times.
51
+ def close
52
+ @mutex.synchronize do
53
+ return if @closed
54
+
55
+ @stopped = true
56
+ @closed = true
57
+ end
58
+ @queue.push(:stop)
59
+ @thread.join
60
+ @output.flush if @output.respond_to?(:flush)
61
+ end
62
+
63
+ private
64
+
65
+ def drain
66
+ loop do
67
+ line = @queue.pop
68
+ break if line == :stop
69
+
70
+ @output.puts(line)
71
+ end
72
+
73
+ # Drain remaining items after stop signal
74
+ drain_remaining
75
+ end
76
+
77
+ def drain_remaining
78
+ loop do
79
+ line = @queue.pop(true)
80
+ @output.puts(line) unless line == :stop
81
+ rescue ThreadError
82
+ break
83
+ end
84
+ end
85
+
86
+ def sync_write(line)
87
+ @output.puts(line)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -29,5 +29,35 @@ module Philiprehberger
29
29
  }
30
30
  end
31
31
  end
32
+
33
+ # Builds plain-text structured log entries.
34
+ class TextFormatter
35
+ # Format a log entry as a human-readable text string.
36
+ #
37
+ # @param level [Symbol] the log level
38
+ # @param message [String] the log message
39
+ # @param context [Hash] merged context data
40
+ # @return [String] formatted text log line
41
+ def call(level, message, context)
42
+ timestamp = Time.now.utc.iso8601(3)
43
+ parts = ["[#{timestamp}] #{level.to_s.upcase}: #{message}"]
44
+ context.each do |key, value|
45
+ parts << "#{key}=#{value}"
46
+ end
47
+ parts.join(" ")
48
+ end
49
+ end
50
+
51
+ def self.resolve_formatter(formatter)
52
+ case formatter
53
+ when nil, :json then Formatter.new
54
+ when :text then TextFormatter.new
55
+ when Proc then formatter
56
+ else
57
+ raise ArgumentError, "Invalid formatter" unless formatter.respond_to?(:call)
58
+
59
+ formatter
60
+ end
61
+ end
32
62
  end
33
63
  end
@@ -1,40 +1,87 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "monitor"
4
+ require "securerandom"
4
5
 
5
6
  module Philiprehberger
6
7
  module StructuredLogger
7
- # Thread-safe structured JSON logger with context and child loggers.
8
8
  class Logger
9
9
  LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
10
10
 
11
- attr_reader :context
11
+ CORRELATION_ID_KEY = :philiprehberger_structured_logger_correlation_id
12
12
 
13
- # @param output [IO] writable output (default: $stdout)
14
- # @param level [Symbol] minimum log level
15
- # @param context [Hash] base context merged into every entry
16
- def initialize(output: $stdout, level: :debug, context: {})
17
- @output = output
18
- @level = level
19
- @context = context.freeze
20
- @formatter = Formatter.new
13
+ attr_reader :context, :level
14
+
15
+ def initialize(**opts)
16
+ @level = opts.fetch(:level, :debug)
17
+ @context = opts.fetch(:context, {}).freeze
18
+ @sampling = opts.fetch(:sampling, {})
19
+ @async = opts.fetch(:async, false)
20
+ @buffer_size = opts.fetch(:buffer_size, 1000)
21
21
  @monitor = Monitor.new
22
+
23
+ @outputs = OutputBuilder.call(opts, @async, @buffer_size)
22
24
  end
23
25
 
24
- # Set the minimum log level.
25
- #
26
- # @param new_level [Symbol]
27
26
  def level=(new_level)
28
27
  validate_level!(new_level)
29
28
  @level = new_level
30
29
  end
31
30
 
32
- # Create a child logger with additional context.
33
- #
34
- # @param extra [Hash] context to merge
35
- # @return [Logger]
31
+ def add_output(io, level: nil, formatter: nil)
32
+ resolved = StructuredLogger.resolve_formatter(formatter)
33
+ wrapped = @async ? AsyncWriter.new(io, buffer_size: @buffer_size) : io
34
+ @monitor.synchronize { @outputs << { io: wrapped, level: level, formatter: resolved } }
35
+ end
36
+
36
37
  def child(**extra)
37
- self.class.new(output: @output, level: @level, context: @context.merge(extra))
38
+ clone = self.class.allocate
39
+ clone.send(:initialize_child, @outputs, @level, @context.merge(extra), @sampling, @monitor)
40
+ clone
41
+ end
42
+
43
+ def with_context(**extra, &block)
44
+ @monitor.synchronize do
45
+ original = @context
46
+ @context = @context.merge(extra).freeze
47
+ block.call
48
+ ensure
49
+ @context = original
50
+ end
51
+ end
52
+
53
+ def silence(temp_level = :fatal, &block)
54
+ @monitor.synchronize do
55
+ original = @level
56
+ @level = temp_level
57
+ block.call
58
+ ensure
59
+ @level = original
60
+ end
61
+ end
62
+
63
+ def with_correlation_id(id = nil, &block)
64
+ id ||= SecureRandom.uuid
65
+ previous = Thread.current[CORRELATION_ID_KEY]
66
+ Thread.current[CORRELATION_ID_KEY] = id
67
+ block.call
68
+ ensure
69
+ Thread.current[CORRELATION_ID_KEY] = previous
70
+ end
71
+
72
+ def log_exception(exception, level: :error, **extra)
73
+ log(level, exception.message,
74
+ error_class: exception.class.name,
75
+ backtrace: exception.backtrace || [],
76
+ **extra)
77
+ end
78
+
79
+ def flush
80
+ @monitor.synchronize { @outputs.each { |out| out[:io].flush if out[:io].respond_to?(:flush) } }
81
+ end
82
+
83
+ def close
84
+ @monitor.synchronize { @outputs.each { |out| out[:io].close if out[:io].is_a?(AsyncWriter) } }
38
85
  end
39
86
 
40
87
  LEVELS.each_key do |lvl|
@@ -45,22 +92,76 @@ module Philiprehberger
45
92
 
46
93
  private
47
94
 
95
+ def initialize_child(outputs, level, context, sampling, monitor)
96
+ @outputs = outputs
97
+ @level = level
98
+ @context = context.freeze
99
+ @sampling = sampling
100
+ @monitor = monitor
101
+ end
102
+
48
103
  def log(level, message, **extra)
49
- return unless should_log?(level)
104
+ return unless LEVELS.fetch(level) >= LEVELS.fetch(@level)
105
+ return unless sample?(level)
50
106
 
51
107
  merged = @context.merge(extra)
52
- line = @formatter.call(level, message, merged)
53
- @monitor.synchronize { @output.puts(line) }
108
+ cid = Thread.current[CORRELATION_ID_KEY]
109
+ merged = merged.merge(correlation_id: cid) if cid
110
+ @monitor.synchronize { write_to_outputs(level, message, merged) }
111
+ end
112
+
113
+ def write_to_outputs(level, message, merged)
114
+ @outputs.each do |out|
115
+ next if out[:level] && LEVELS.fetch(level) < LEVELS.fetch(out[:level])
116
+
117
+ out[:io].puts(out[:formatter].call(level, message, merged))
118
+ end
54
119
  end
55
120
 
56
- def should_log?(level)
57
- LEVELS.fetch(level) >= LEVELS.fetch(@level)
121
+ def sample?(level)
122
+ rate = @sampling.fetch(level, 1.0)
123
+ rate >= 1.0 || (rate > 0.0 && rand < rate)
58
124
  end
59
125
 
60
126
  def validate_level!(level)
61
- return if LEVELS.key?(level)
127
+ raise ArgumentError, "Invalid level: #{level}" unless LEVELS.key?(level)
128
+ end
129
+ end
130
+
131
+ # Builds output configuration from constructor options.
132
+ module OutputBuilder
133
+ module_function
134
+
135
+ def call(opts, async, buffer_size)
136
+ outputs = opts[:outputs]
137
+ if outputs
138
+ build_multi(outputs, opts[:formatter], async, buffer_size)
139
+ else
140
+ build_single(opts[:output], opts[:formatter], async, buffer_size)
141
+ end
142
+ end
143
+
144
+ def build_multi(outputs, default_formatter, async, buffer_size)
145
+ outputs.map do |out|
146
+ if out.is_a?(Hash)
147
+ build_hash_output(out, async, buffer_size)
148
+ else
149
+ io = async ? AsyncWriter.new(out, buffer_size: buffer_size) : out
150
+ { io: io, level: nil, formatter: StructuredLogger.resolve_formatter(default_formatter) }
151
+ end
152
+ end
153
+ end
154
+
155
+ def build_hash_output(out, async, buffer_size)
156
+ io = async ? AsyncWriter.new(out[:io], buffer_size: buffer_size) : out[:io]
157
+ fmt = StructuredLogger.resolve_formatter(out[:formatter])
158
+ { io: io, level: out[:level], formatter: fmt }
159
+ end
62
160
 
63
- raise ArgumentError, "Invalid level: #{level}. Valid: #{LEVELS.keys.join(', ')}"
161
+ def build_single(output, formatter, async, buffer_size)
162
+ io = output || $stdout
163
+ io = AsyncWriter.new(io, buffer_size: buffer_size) if async
164
+ [{ io: io, level: nil, formatter: StructuredLogger.resolve_formatter(formatter) }]
64
165
  end
65
166
  end
66
167
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module StructuredLogger
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "structured_logger/version"
4
4
  require_relative "structured_logger/formatter"
5
+ require_relative "structured_logger/async_writer"
5
6
  require_relative "structured_logger/logger"
6
7
 
7
8
  module Philiprehberger
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-structured_logger
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-11 00:00:00.000000000 Z
11
+ date: 2026-03-17 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A zero-dependency Ruby gem for structured JSON logging with context merging,
14
14
  child loggers, level filtering, and pluggable outputs.
@@ -22,6 +22,7 @@ files:
22
22
  - LICENSE
23
23
  - README.md
24
24
  - lib/philiprehberger/structured_logger.rb
25
+ - lib/philiprehberger/structured_logger/async_writer.rb
25
26
  - lib/philiprehberger/structured_logger/formatter.rb
26
27
  - lib/philiprehberger/structured_logger/logger.rb
27
28
  - lib/philiprehberger/structured_logger/version.rb