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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +196 -6
- data/lib/philiprehberger/structured_logger/async_writer.rb +91 -0
- data/lib/philiprehberger/structured_logger/formatter.rb +30 -0
- data/lib/philiprehberger/structured_logger/logger.rb +126 -25
- data/lib/philiprehberger/structured_logger/version.rb +1 -1
- data/lib/philiprehberger/structured_logger.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa759b5a2e0251937f0ab9951c533f233ef8129a4b13c724995bf98699618374
|
|
4
|
+
data.tar.gz: 5908a03340aaf26b1b0a428d7756874f6fc6caabe14a137fad5fec8e7e3110a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
94
|
+
Temporarily suppress log output by raising the minimum level:
|
|
73
95
|
|
|
74
96
|
```ruby
|
|
75
|
-
|
|
76
|
-
logger
|
|
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
|
-
|
|
11
|
+
CORRELATION_ID_KEY = :philiprehberger_structured_logger_correlation_id
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@
|
|
18
|
-
@
|
|
19
|
-
@
|
|
20
|
-
@
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
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
|
|
104
|
+
return unless LEVELS.fetch(level) >= LEVELS.fetch(@level)
|
|
105
|
+
return unless sample?(level)
|
|
50
106
|
|
|
51
107
|
merged = @context.merge(extra)
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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.
|
|
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
|
+
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
|