legion-logging 1.5.6 → 1.5.21

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.
@@ -0,0 +1,492 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Logging
5
+ # The descriptors themselves become pipe writers, including writes which do
6
+ # not pass through Ruby's Logger. Only the drain thread owns the file sink.
7
+ class StdioRotator
8
+ class << self
9
+ attr_reader :current
10
+
11
+ def shared(max_bytes:, max_files:)
12
+ inherited = @current
13
+ if inherited && !inherited.owned?
14
+ raise IOError, 'cannot change the policy of an inherited stdio capture' unless inherited.max_bytes == max_bytes && inherited.max_files == max_files
15
+
16
+ return inherited
17
+ end
18
+
19
+ @shared_mutex.synchronize do
20
+ return @current.configure(max_bytes: max_bytes, max_files: max_files) if @current
21
+
22
+ capture = new(max_bytes: max_bytes, max_files: max_files)
23
+ @current = capture if capture.capturing?
24
+ end
25
+ end
26
+
27
+ def reset!(timeout: 2)
28
+ return false unless timeout.nil? ? @shared_mutex.lock : @shared_mutex.try_lock
29
+
30
+ begin
31
+ return true unless @current
32
+ return false unless @current.shutdown(timeout: timeout)
33
+
34
+ @current = nil
35
+ true
36
+ ensure
37
+ @shared_mutex.unlock
38
+ end
39
+ end
40
+ end
41
+ @shared_mutex = Mutex.new
42
+
43
+ attr_reader :max_bytes, :max_files
44
+
45
+ def initialize(max_bytes:, max_files:, stdout: $stdout, stderr: $stderr)
46
+ @stdout = stdout
47
+ @streams = [stdout, stderr].uniq(&:object_id)
48
+ @lifecycle = Mutex.new
49
+ @captures = []
50
+ @closed = false
51
+ @owner_pid = ::Process.pid
52
+ @max_bytes = max_bytes
53
+ @max_files = max_files
54
+ eligible = @streams.filter_map do |stream|
55
+ path = Rotation.regular_file_path(io: stream)
56
+ stream.flush if path
57
+ [stream, path, stream.stat] if path
58
+ end
59
+ eligible.group_by { |_, _, stat| [stat.dev, stat.ino] }.each_value do |group|
60
+ @captures << StdioCapture.new(path: group.first[1], streams: group.map(&:first), max_bytes: max_bytes, max_files: max_files)
61
+ end
62
+ @captures.each(&:attach)
63
+ @captures.each(&:release_backups)
64
+ rescue StandardError
65
+ @captures.reverse_each(&:rollback)
66
+ raise
67
+ end
68
+
69
+ def capturing? = !@captures.empty? && !@closed
70
+
71
+ def owned? = @owner_pid == ::Process.pid
72
+
73
+ def configure(max_bytes:, max_files:)
74
+ raise IOError, 'cannot configure an inherited stdio capture' unless owned?
75
+
76
+ @lifecycle.synchronize do
77
+ raise IOError, 'closed stdio capture' if @closed
78
+
79
+ @captures.each { |capture| capture.command(type: :configure, arguments: { max_bytes: max_bytes, max_files: max_files }) }
80
+ @max_bytes = max_bytes
81
+ @max_files = max_files
82
+ end
83
+ self
84
+ end
85
+
86
+ def write(*messages)
87
+ if @closed
88
+ current = self.class.current
89
+ return current.write(*messages) if current && !current.equal?(self) && current.stdout.equal?(@stdout)
90
+
91
+ return @stdout.write(*messages)
92
+ end
93
+
94
+ @captures.each(&:check_failure!) if owned?
95
+ @stdout.write(*messages)
96
+ rescue IOError, SystemCallError
97
+ @captures.each(&:check_failure!) if owned? && !@closed
98
+ raise
99
+ end
100
+
101
+ def flush(timeout: 2)
102
+ return false unless owned?
103
+
104
+ if @closed
105
+ current = self.class.current
106
+ return current.flush(timeout: timeout) if current && !current.equal?(self) && current.stdout.equal?(@stdout)
107
+
108
+ @stdout.flush
109
+ return self
110
+ end
111
+
112
+ deadline = monotonic_time + timeout if timeout
113
+ @captures.each { |capture| return false unless capture.flush(deadline: deadline) }
114
+ @stdout.flush unless @captures.any? { |capture| capture.includes?(stream: @stdout) }
115
+ self
116
+ end
117
+
118
+ # Logger borrows process stdio. Only explicit shutdown/reset! detaches it.
119
+ def close; end
120
+
121
+ def closed? = @closed
122
+
123
+ def sync = true # rubocop:disable Naming/PredicateMethod -- IO#sync interface
124
+
125
+ def sync=(_value); end
126
+
127
+ def rotate!
128
+ raise IOError, 'cannot rotate an inherited stdio capture' unless owned?
129
+
130
+ @lifecycle.synchronize do
131
+ raise IOError, 'closed stdio capture' if @closed
132
+
133
+ @captures.each { |capture| capture.command(type: :rotate!, arguments: {}) }
134
+ !@captures.empty?
135
+ end
136
+ end
137
+
138
+ def shutdown(timeout: 2)
139
+ return false unless owned?
140
+ return false unless timeout.nil? ? @lifecycle.lock : @lifecycle.try_lock
141
+
142
+ deadline = monotonic_time + timeout if timeout
143
+ detached = false
144
+ begin
145
+ return true if @closed
146
+ return false unless @captures.all? { |capture| capture.pause(deadline: deadline) }
147
+
148
+ detached = true
149
+ @closed = drain_and_detach!(deadline: deadline)
150
+ ensure
151
+ begin
152
+ unless @closed
153
+ @captures.each(&:attach) if detached
154
+ @captures.each(&:resume)
155
+ end
156
+ ensure
157
+ @lifecycle.unlock
158
+ end
159
+ end
160
+ end
161
+
162
+ protected
163
+
164
+ attr_reader :stdout
165
+
166
+ private
167
+
168
+ def drain_and_detach!(deadline:)
169
+ @captures.each(&:restore)
170
+ @captures.each(&:resume)
171
+ # Include bytes written to the old descriptors during restoration.
172
+ return false unless @captures.all? { |capture| capture.pause(deadline: deadline) }
173
+
174
+ # A final drain may itself rotate: restore the current active inode.
175
+ @captures.each(&:restore)
176
+ @captures.all? { |capture| capture.finish!(deadline: deadline) }
177
+ end
178
+
179
+ def monotonic_time
180
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
181
+ end
182
+ end
183
+
184
+ # One reader and one bounded control pipe per distinct inherited file.
185
+ # Commands occupy a single slot; bytes are never queued in Ruby.
186
+ class StdioCapture
187
+ READ_BYTES = 16 * 1024
188
+
189
+ def initialize(path:, streams:, max_bytes:, max_files:)
190
+ @path = path
191
+ @streams = streams
192
+ @state = Mutex.new
193
+ @changed = ConditionVariable.new
194
+ @requested_flush = 0
195
+ @completed_flush = 0
196
+ @backups = []
197
+ @properties = streams.map { |stream| [stream.sync, stream.close_on_exec?, stream.external_encoding, stream.internal_encoding] }
198
+ streams.each { |stream| @backups << stream.dup }
199
+ @sink = RotatingFile.new(path: path, max_bytes: max_bytes, max_files: max_files)
200
+ @reader, @writer = IO.pipe
201
+ @wake_reader, @wake_writer = IO.pipe
202
+ @writer.binmode
203
+ @writer.sync = true
204
+ @worker = Thread.new do
205
+ Thread.current.report_on_exception = false
206
+ drain
207
+ end
208
+ rescue StandardError
209
+ begin
210
+ close_handles
211
+ ensure
212
+ release_backups
213
+ end
214
+ raise
215
+ end
216
+
217
+ def includes?(stream:)
218
+ @streams.any? { |candidate| candidate.equal?(stream) }
219
+ end
220
+
221
+ def attach
222
+ writer = @state.synchronize { @writer.dup unless @writer.closed? }
223
+ unless writer
224
+ # A failure during shutdown must not leave restored regular-file
225
+ # descriptors as an uncapped fallback. A readerless pipe fails closed.
226
+ reader, writer = IO.pipe
227
+ reader.close
228
+ end
229
+ @streams.each_with_index do |stream, index|
230
+ next if stream.closed?
231
+
232
+ stream.reopen(writer)
233
+ apply_properties(stream: stream, index: index, captured: true)
234
+ end
235
+ rescue StandardError
236
+ # Construction still has original descriptors available for rollback.
237
+ raise unless @backups.empty?
238
+
239
+ # If even dup/reopen fails, close the destinations rather than leave a
240
+ # restored regular-file descriptor able to bypass the retention cap.
241
+ failure = nil
242
+ @streams.each do |stream|
243
+ stream.close unless stream.closed?
244
+ rescue StandardError => e
245
+ failure ||= e
246
+ end
247
+ raise failure if failure
248
+
249
+ raise
250
+ ensure
251
+ writer.close if writer && !writer.closed?
252
+ end
253
+
254
+ def restore
255
+ file = @state.synchronize do
256
+ prepared = @restore_file
257
+ @restore_file = nil
258
+ prepared
259
+ end
260
+ raise IOError, 'stdio restoration was not prepared' unless file
261
+
262
+ @streams.each_with_index do |stream, index|
263
+ next if stream.closed?
264
+
265
+ stream.reopen(file)
266
+ apply_properties(stream: stream, index: index, captured: false)
267
+ end
268
+ ensure
269
+ file&.close
270
+ end
271
+
272
+ def release_backups
273
+ failure = nil
274
+ @backups.each do |backup|
275
+ backup.close unless backup.closed?
276
+ rescue StandardError => e
277
+ failure ||= e
278
+ end
279
+ @backups.clear
280
+ raise failure if failure
281
+ end
282
+
283
+ def rollback
284
+ @streams.each_with_index do |stream, index|
285
+ stream.reopen(@backups[index])
286
+ apply_properties(stream: stream, index: index, captured: false)
287
+ end
288
+ ensure
289
+ begin
290
+ finish!
291
+ ensure
292
+ release_backups
293
+ end
294
+ end
295
+
296
+ def check_failure!
297
+ @state.synchronize do
298
+ raise @failure if @failure
299
+ raise IOError, 'stdio capture is stopping' if @stopping
300
+ end
301
+ end
302
+
303
+ def flush(deadline:)
304
+ wait_for_flush(deadline: deadline, pause: false)
305
+ end
306
+
307
+ def pause(deadline:)
308
+ wait_for_flush(deadline: deadline, pause: true)
309
+ end
310
+
311
+ def resume
312
+ file = @state.synchronize do
313
+ @pause_target = nil
314
+ prepared = @restore_file
315
+ @restore_file = nil
316
+ prepared
317
+ end
318
+ file&.close
319
+ @state.synchronize do
320
+ @paused = false
321
+ wake unless @failure || @stopping
322
+ end
323
+ end
324
+
325
+ def finish!(deadline: nil) # rubocop:disable Naming/PredicateMethod -- Stops and joins the worker; false signals its deadline.
326
+ @state.synchronize do
327
+ unless @stopping
328
+ @stopping = true
329
+ wake unless @failure
330
+ end
331
+ end
332
+ remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
333
+ !@worker.join(remaining).nil?
334
+ end
335
+
336
+ def command(type:, arguments:)
337
+ @state.synchronize do
338
+ raise @failure if @failure
339
+ raise IOError, 'stdio capture is stopping' if @stopping
340
+
341
+ wait_until { !@command }
342
+
343
+ command = { type: type, arguments: arguments, complete: false }
344
+ @command = command
345
+ wake
346
+ wait_until { command[:complete] }
347
+ command[:result]
348
+ end
349
+ end
350
+
351
+ private
352
+
353
+ def apply_properties(stream:, index:, captured:)
354
+ sync, close_on_exec, external, internal = @properties[index]
355
+ stream.sync = captured || sync
356
+ stream.close_on_exec = close_on_exec
357
+ stream.set_encoding(external, internal) if external
358
+ end
359
+
360
+ def wait_for_flush(deadline:, pause:)
361
+ @state.synchronize do
362
+ raise @failure if @failure
363
+ raise IOError, 'stdio capture is stopping' if @stopping
364
+
365
+ target = @requested_flush += 1
366
+ @pause_target = target if pause
367
+ wake
368
+ wait_until(deadline: deadline) { @completed_flush >= target && (!pause || @paused) }
369
+ end
370
+ end
371
+
372
+ def wait_until(deadline: nil)
373
+ loop do
374
+ raise @failure if @failure
375
+ return true if yield
376
+
377
+ if deadline
378
+ remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
379
+ return false unless remaining.positive?
380
+ end
381
+
382
+ @changed.wait(@state, remaining)
383
+ end
384
+ end
385
+
386
+ def wake
387
+ return if @wake_pending
388
+
389
+ @wake_writer.write_nonblock('.')
390
+ @wake_pending = true
391
+ end
392
+
393
+ def next_action
394
+ @state.synchronize do
395
+ return [:stop] if @stopping
396
+ return [:wait] if @paused
397
+ return [:command, @command] if @command
398
+
399
+ # Only this thread completes flushes, after both the read and its
400
+ # corresponding sink write. A consumed chunk cannot hide from flush.
401
+ bytes = @reader.read_nonblock(READ_BYTES, exception: false)
402
+ return [:write, bytes] if bytes.is_a?(String)
403
+ return [:flush, @requested_flush] if @requested_flush > @completed_flush
404
+
405
+ [:wait]
406
+ end
407
+ end
408
+
409
+ def drain
410
+ loop do
411
+ action, payload = next_action
412
+ case action
413
+ when :stop then break
414
+ when :wait then wait_for_input
415
+ when :write then @sink.write(payload)
416
+ when :command then execute_command(command: payload)
417
+ when :flush then complete_flush(target: payload)
418
+ end
419
+ end
420
+ rescue StandardError => e
421
+ # Closing the read end wakes blocked producers with EPIPE. Never log a
422
+ # drain failure to stderr: stderr may be the pipe which just failed.
423
+ begin
424
+ @reader.close unless @reader.closed?
425
+ ensure
426
+ @state.synchronize do
427
+ @failure = e
428
+ @changed.broadcast
429
+ end
430
+ end
431
+ raise
432
+ ensure
433
+ close_handles
434
+ end
435
+
436
+ def wait_for_input
437
+ IO.select(@paused ? [@wake_reader] : [@reader, @wake_reader])
438
+ @state.synchronize do
439
+ @wake_reader.read_nonblock(READ_BYTES, exception: false)
440
+ @wake_pending = false
441
+ end
442
+ end
443
+
444
+ def execute_command(command:)
445
+ result = @sink.public_send(command[:type], **command[:arguments])
446
+ @state.synchronize do
447
+ command[:result] = result
448
+ command[:complete] = true
449
+ @command = nil
450
+ @changed.broadcast
451
+ end
452
+ end
453
+
454
+ def complete_flush(target:)
455
+ @sink.flush
456
+ prepare = @state.synchronize { @pause_target && target >= @pause_target }
457
+ file = File.new(@path, 'ab') if prepare
458
+ retained = false
459
+ @state.synchronize do
460
+ @completed_flush = target
461
+ if file && @pause_target && target >= @pause_target
462
+ @restore_file = file
463
+ @paused = true
464
+ retained = true
465
+ end
466
+ @changed.broadcast
467
+ end
468
+ ensure
469
+ file.close if file && !retained
470
+ end
471
+
472
+ def close_handles
473
+ failure = nil
474
+ [@reader, @writer, @wake_reader, @wake_writer, @restore_file, @sink].compact.each do |handle|
475
+ if handle.equal?(@writer)
476
+ @state.synchronize { handle.close unless handle.closed? }
477
+ else
478
+ handle.close unless handle.closed?
479
+ end
480
+ rescue StandardError => e
481
+ failure ||= e
482
+ @state.synchronize do
483
+ @failure ||= e
484
+ @changed.broadcast
485
+ end
486
+ end
487
+ raise failure if failure
488
+ end
489
+ end
490
+ private_constant :StdioCapture
491
+ end
492
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Logging
5
- VERSION = '1.5.6'
5
+ VERSION = '1.5.21'
6
6
  end
7
7
  end
@@ -76,36 +76,41 @@ module Legion
76
76
  Hooks.clear_hooks!
77
77
  end
78
78
 
79
- def setup(level: 'debug', format: :text, async: true, **options)
80
- output(**options)
81
- log_level(level)
82
- log_format(format: format, **options)
83
- @color = options[:color]
84
- @color = format != :json && (options[:color] || (options[:color].nil? && options[:log_file].nil?))
85
- @current_settings = {
86
- level: level,
87
- format: format.to_sym,
88
- async: async,
89
- trace: options.fetch(:trace, true),
90
- trace_size: options.fetch(:trace_size, 4),
91
- extended: options.fetch(:extended, true),
92
- log_file: options[:log_file],
93
- log_stdout: options[:log_stdout],
94
- include_pid: options.fetch(:include_pid, false),
95
- color: @color
96
- }.freeze
97
- @configuration_generation = configuration_generation + 1
98
- Legion::Logging::Redactor.refresh_patterns! if defined?(Legion::Logging::Redactor)
99
- if async
100
- buffer = if defined?(Legion::Settings) && Legion::Settings.respond_to?(:[])
101
- logging_settings = Legion::Settings[:logging]
102
- async_settings = logging_settings[:async] if logging_settings.is_a?(Hash)
103
- async_settings[:buffer_size] if async_settings.is_a?(Hash)
104
- end
105
- buffer ||= 10_000
106
- start_async_writer(buffer_size: buffer)
107
- else
108
- stop_async_writer
79
+ def setup(level: 'debug', format: :text, async: true, shutdown_timeout: 2, **options)
80
+ with_lifecycle_lock do
81
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + shutdown_timeout if shutdown_timeout
82
+ return false if @async_writer && stop_async_writer(timeout: shutdown_timeout) == false
83
+
84
+ remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
85
+ return false if output(shutdown_timeout: remaining, **options) == false
86
+
87
+ log_level(level)
88
+ log_format(format: format, **options)
89
+ @color = options[:color]
90
+ @color = format != :json && (options[:color] || (options[:color].nil? && options[:log_file].nil?))
91
+ @current_settings = {
92
+ level: level,
93
+ format: format.to_sym,
94
+ async: async,
95
+ trace: options.fetch(:trace, true),
96
+ trace_size: options.fetch(:trace_size, 4),
97
+ extended: options.fetch(:extended, true),
98
+ log_file: options[:log_file],
99
+ log_stdout: options[:log_stdout],
100
+ rotation: options[:rotation],
101
+ include_pid: options.fetch(:include_pid, false),
102
+ color: @color
103
+ }.freeze
104
+ @configuration_generation = configuration_generation + 1
105
+ Legion::Logging::Redactor.refresh_patterns! if defined?(Legion::Logging::Redactor)
106
+ if async
107
+ start_async_writer(buffer_size: Legion::Logging::Settings.async_buffer_size)
108
+ else
109
+ @async_writer = nil
110
+ @async = false
111
+ log.async_writer = nil
112
+ true
113
+ end
109
114
  end
110
115
  end
111
116
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-logging
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.6
4
+ version: 1.5.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -70,12 +70,16 @@ files:
70
70
  - lib/legion/logging/method_tracer.rb
71
71
  - lib/legion/logging/methods.rb
72
72
  - lib/legion/logging/multi_io.rb
73
+ - lib/legion/logging/output_logger.rb
73
74
  - lib/legion/logging/redactor.rb
75
+ - lib/legion/logging/rotating_file.rb
76
+ - lib/legion/logging/rotation.rb
74
77
  - lib/legion/logging/settings.rb
75
78
  - lib/legion/logging/shipper.rb
76
79
  - lib/legion/logging/shipper/file_transport.rb
77
80
  - lib/legion/logging/shipper/http_transport.rb
78
81
  - lib/legion/logging/siem_exporter.rb
82
+ - lib/legion/logging/stdio_rotator.rb
79
83
  - lib/legion/logging/tagged_logger.rb
80
84
  - lib/legion/logging/version.rb
81
85
  - lib/legion/service.rb