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.
@@ -13,6 +13,7 @@ module Legion
13
13
  exchanges|queues|messages|data|builders|tools|adapters|engines|
14
14
  formatters|parsers|middleware)/
15
15
  }x
16
+ LOGGING_PATH = __dir__
16
17
 
17
18
  def trace(raw_message = nil, size: @trace_size, log_caller: true)
18
19
  return unless @trace_enabled
@@ -25,18 +26,24 @@ module Legion
25
26
  elsif log_caller
26
27
  message.concat Rainbow(caller_locations[0..size]).cyan.underline
27
28
  end
28
- log.unknown(message)
29
+ unknown(message)
29
30
  end
30
31
 
31
32
  def debug(message = nil, task_id: nil, conv_id: nil, request_id: nil, exchange_id: nil, chain_id: nil, **_ctx)
32
33
  return unless log.level < 1
33
34
 
34
35
  message = yield if message.nil? && block_given?
36
+ if async_producer?
37
+ return defer_log_call(receiver: self, method_name: :debug, arguments: [message],
38
+ keywords: { task_id: task_id, conv_id: conv_id, request_id: request_id,
39
+ exchange_id: exchange_id, chain_id: chain_id })
40
+ end
41
+
35
42
  raw = maybe_redact(message)
36
43
  formatted = format_message_for_level(:debug, raw)
37
44
  with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
38
45
  exchange_id: exchange_id, chain_id: chain_id) do
39
- write_async_or_sync(:debug, formatted, raw)
46
+ write_async_or_sync(:debug, formatted, raw, writer_context: build_writer_context(:debug, raw))
40
47
  end
41
48
  end
42
49
 
@@ -44,11 +51,17 @@ module Legion
44
51
  return unless log.level < 2
45
52
 
46
53
  message = yield if message.nil? && block_given?
54
+ if async_producer?
55
+ return defer_log_call(receiver: self, method_name: :info, arguments: [message],
56
+ keywords: { task_id: task_id, conv_id: conv_id, request_id: request_id,
57
+ exchange_id: exchange_id, chain_id: chain_id })
58
+ end
59
+
47
60
  raw = maybe_redact(message)
48
61
  formatted = format_message_for_level(:info, raw)
49
62
  with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
50
63
  exchange_id: exchange_id, chain_id: chain_id) do
51
- write_async_or_sync(:info, formatted, raw)
64
+ write_async_or_sync(:info, formatted, raw, writer_context: build_writer_context(:info, raw))
52
65
  end
53
66
  end
54
67
 
@@ -56,6 +69,12 @@ module Legion
56
69
  return unless log.level < 3
57
70
 
58
71
  message = yield if message.nil? && block_given?
72
+ if async_producer?
73
+ return defer_log_call(receiver: self, method_name: :warn, arguments: [message],
74
+ keywords: { task_id: task_id, conv_id: conv_id, request_id: request_id,
75
+ exchange_id: exchange_id, chain_id: chain_id })
76
+ end
77
+
59
78
  raw = maybe_redact(message)
60
79
  formatted = format_message_for_level(:warn, raw)
61
80
  with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
@@ -68,6 +87,12 @@ module Legion
68
87
  return unless log.level < 4
69
88
 
70
89
  message = yield if message.nil? && block_given?
90
+ if async_producer?
91
+ return defer_log_call(receiver: self, method_name: :error, arguments: [message],
92
+ keywords: { task_id: task_id, conv_id: conv_id, request_id: request_id,
93
+ exchange_id: exchange_id, chain_id: chain_id })
94
+ end
95
+
71
96
  raw = maybe_redact(message)
72
97
  formatted = format_message_for_level(:error, raw)
73
98
  with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
@@ -80,6 +105,12 @@ module Legion
80
105
  return unless log.level < 5
81
106
 
82
107
  message = yield if message.nil? && block_given?
108
+ if async_producer?
109
+ return defer_log_call(receiver: self, method_name: :fatal, arguments: [message],
110
+ keywords: { task_id: task_id, conv_id: conv_id, request_id: request_id,
111
+ exchange_id: exchange_id, chain_id: chain_id })
112
+ end
113
+
83
114
  raw = maybe_redact(message)
84
115
  formatted = format_message_for_level(:fatal, raw)
85
116
  with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
@@ -90,11 +121,17 @@ module Legion
90
121
 
91
122
  def unknown(message = nil, task_id: nil, conv_id: nil, request_id: nil, exchange_id: nil, chain_id: nil, **_ctx)
92
123
  message = yield if message.nil? && block_given?
124
+ if async_producer?
125
+ return defer_log_call(receiver: self, method_name: :unknown, arguments: [message],
126
+ keywords: { task_id: task_id, conv_id: conv_id, request_id: request_id,
127
+ exchange_id: exchange_id, chain_id: chain_id })
128
+ end
129
+
93
130
  raw = maybe_redact(message)
94
131
  formatted = format_message_for_level(:unknown, raw)
95
132
  with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
96
133
  exchange_id: exchange_id, chain_id: chain_id) do
97
- write_async_or_sync(:unknown, formatted, raw)
134
+ write_async_or_sync(:unknown, formatted, raw, writer_context: build_writer_context(:unknown, raw))
98
135
  end
99
136
  end
100
137
 
@@ -103,6 +140,11 @@ module Legion
103
140
  level = level.to_sym
104
141
  message = yield if message.nil? && block_given?
105
142
  return if message.nil?
143
+ if async_producer?
144
+ return defer_log_call(receiver: self, method_name: :emit_tagged, arguments: [level, message],
145
+ keywords: { segments: segments, method_ctx: method_ctx, task_id: task_id, conv_id: conv_id,
146
+ request_id: request_id, exchange_id: exchange_id, chain_id: chain_id })
147
+ end
106
148
 
107
149
  raw = maybe_redact(message)
108
150
  formatted = format_message_for_level(level, raw)
@@ -110,23 +152,21 @@ module Legion
110
152
  with_tagged_context(segments, method_ctx) do
111
153
  with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
112
154
  exchange_id: exchange_id, chain_id: chain_id) do
113
- ctx = %i[warn error fatal].include?(level) ? build_writer_context(level, raw) : nil
155
+ ctx = build_writer_context(level, raw)
114
156
  writer = @async_writer
115
157
  caller_trace = capture_runner_trace_for_async
116
- if writer&.alive?
117
- writer.push(AsyncWriter::LogEntry.new(
118
- level: level, message: formatted, writer_context: ctx,
119
- segments: Thread.current[:legion_log_segments],
120
- method_ctx: Thread.current[:legion_log_method],
121
- caller_trace: caller_trace,
122
- conv_id: Thread.current[:legion_log_conv_id],
123
- request_id: Thread.current[:legion_log_request_id],
124
- exchange_id: Thread.current[:legion_log_exchange_id],
125
- chain_id: Thread.current[:legion_log_chain_id]
126
- ))
158
+ if @async || writer
159
+ push_async_entry(entry: AsyncWriter::LogEntry.new(force: true, level: level, message: formatted, writer_context: ctx,
160
+ segments: Thread.current[:legion_log_segments],
161
+ method_ctx: Thread.current[:legion_log_method],
162
+ caller_trace: caller_trace,
163
+ conv_id: Thread.current[:legion_log_conv_id],
164
+ request_id: Thread.current[:legion_log_request_id],
165
+ exchange_id: Thread.current[:legion_log_exchange_id],
166
+ chain_id: Thread.current[:legion_log_chain_id]))
127
167
  else
128
168
  with_caller_trace(caller_trace) { write_forced(level, formatted) }
129
- fire_log_writer(level, raw) if ctx
169
+ fire_log_writer(level, raw, event: ctx[:event]) if ctx
130
170
  end
131
171
  end
132
172
  end
@@ -141,6 +181,14 @@ module Legion
141
181
  gem_name: nil, lex_version: nil, gem_path: nil,
142
182
  source_code_uri: nil, handled: false, payload_summary: nil,
143
183
  task_id: nil, backtrace_limit: nil, **extra)
184
+ if async_producer?
185
+ return defer_log_call(receiver: self, method_name: :log_exception, arguments: [exception],
186
+ keywords: { level: level, lex: lex, component_type: component_type, gem_name: gem_name,
187
+ lex_version: lex_version, gem_path: gem_path, source_code_uri: source_code_uri,
188
+ handled: handled, payload_summary: payload_summary, task_id: task_id,
189
+ backtrace_limit: backtrace_limit, **extra })
190
+ end
191
+
144
192
  level = level.to_sym if level.respond_to?(:to_sym)
145
193
  # 1. Log human-readable line + backtrace via async writer
146
194
  msg = exception.respond_to?(:message) ? exception.message : exception.to_s
@@ -170,7 +218,7 @@ module Legion
170
218
  event = Legion::Logging::Redactor.redact(event) if defined?(Legion::Logging::Redactor)
171
219
 
172
220
  # 4. Publish rich event via exception_writer
173
- publish_exception_event(event, level)
221
+ publish_exception(event: event, level: level)
174
222
  rescue StandardError => e
175
223
  if respond_to?(:log) && log.respond_to?(:warn)
176
224
  log.warn("Failed to publish structured exception event: #{e.class}: #{e.message}")
@@ -179,6 +227,45 @@ module Legion
179
227
  end
180
228
  end
181
229
 
230
+ def publish_exception(event:, level:)
231
+ lex_name = event[:lex] || 'core'
232
+ component = event[:component_type] || :unknown
233
+ options = {
234
+ routing_key: "legion.logging.exception.#{level}.#{lex_name}.#{component}",
235
+ headers: build_exception_headers(event, component, level),
236
+ properties: build_exception_properties(event, level)
237
+ }
238
+ callback = Legion::Logging.exception_writer
239
+ writer = @async_writer
240
+ return push_async_entry(entry: AsyncWriter::EventEntry.new(callback: callback, event: event, options: options)) if @async || writer
241
+
242
+ callback.call(event, **options)
243
+ end
244
+
245
+ def async_producer?
246
+ (@async == true || !@async_writer.nil?) && !@async_writer&.current_thread?
247
+ end
248
+
249
+ def defer_log_call(receiver:, method_name:, arguments:, keywords:)
250
+ return false unless async_producer?
251
+ raise ClosedQueueError, 'async logging writer is unavailable' unless @async_writer&.alive?
252
+
253
+ context = Legion::Logging::AsyncWriter::CALL_THREAD_KEYS.to_h do |key|
254
+ [key, Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[key])]
255
+ end
256
+ location = caller_locations(1, 16).find do |frame|
257
+ path = frame.absolute_path || frame.path
258
+ !path.start_with?(LOGGING_PATH)
259
+ end
260
+ context[:legion_log_origin] = { location: location, timestamp: Time.now.utc, thread: Thread.current.object_id }
261
+ push_async_entry(entry: Legion::Logging::AsyncWriter::CallEntry.new(
262
+ receiver: receiver, method_name: method_name,
263
+ arguments: arguments.map { |value| Legion::Logging::AsyncWriter.snapshot_value(value: value) },
264
+ keywords: keywords.transform_values { |value| Legion::Logging::AsyncWriter.snapshot_value(value: value) },
265
+ context: context
266
+ ))
267
+ end
268
+
182
269
  def thread(kvl: false, **_opts)
183
270
  if kvl
184
271
  "thread=#{Thread.current.object_id}"
@@ -192,8 +279,9 @@ module Legion
192
279
  def resolve_backtrace_limit(explicit_limit)
193
280
  return explicit_limit unless explicit_limit.nil?
194
281
  return nil unless defined?(Legion::Settings)
282
+ return nil unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
195
283
 
196
- Legion::Settings[:logging][:backtrace_limit]
284
+ Legion::Settings.dig(:logging, :backtrace_limit)
197
285
  end
198
286
 
199
287
  def build_exception_log_message(exception, msg, backtrace_limit)
@@ -291,30 +379,35 @@ module Legion
291
379
  def write_async_or_sync(level, formatted_message, raw_message, writer_context: nil)
292
380
  writer = @async_writer
293
381
  caller_trace = capture_runner_trace_for_async
294
- if writer&.alive?
295
- queued = writer.push(AsyncWriter::LogEntry.new(
296
- level: level,
297
- message: formatted_message,
298
- writer_context: writer_context,
299
- segments: Thread.current[:legion_log_segments],
300
- method_ctx: Thread.current[:legion_log_method],
301
- caller_trace: caller_trace,
302
- conv_id: Thread.current[:legion_log_conv_id],
303
- request_id: Thread.current[:legion_log_request_id],
304
- exchange_id: Thread.current[:legion_log_exchange_id],
305
- chain_id: Thread.current[:legion_log_chain_id]
306
- ))
307
- return if queued
382
+ if @async || writer
383
+ return push_async_entry(entry: AsyncWriter::LogEntry.new(force: false, level: level,
384
+ message: formatted_message,
385
+ writer_context: writer_context,
386
+ segments: Thread.current[:legion_log_segments],
387
+ method_ctx: Thread.current[:legion_log_method],
388
+ caller_trace: caller_trace,
389
+ conv_id: Thread.current[:legion_log_conv_id],
390
+ request_id: Thread.current[:legion_log_request_id],
391
+ exchange_id: Thread.current[:legion_log_exchange_id],
392
+ chain_id: Thread.current[:legion_log_chain_id]))
308
393
  end
309
394
 
310
395
  with_caller_trace(caller_trace) do
311
396
  log.public_send(level, formatted_message)
312
- fire_log_writer(level, raw_message) if writer_context
397
+ fire_log_writer(level, raw_message, event: writer_context[:event]) if writer_context
313
398
  end
314
399
  end
315
400
 
401
+ def push_async_entry(entry:) # rubocop:disable Naming/PredicateMethod
402
+ writer = @async_writer
403
+ raise ClosedQueueError, 'async logging writer is unavailable' unless writer&.alive? && writer.push(entry)
404
+
405
+ true
406
+ end
407
+
316
408
  def capture_runner_trace_for_async
317
- build_runner_trace(caller_locations(5, 1)&.first)
409
+ location = Thread.current[:legion_log_origin]&.[](:location) || caller_locations(5, 1)&.first
410
+ build_runner_trace(location)
318
411
  end
319
412
 
320
413
  def with_caller_trace(caller_trace)
@@ -350,19 +443,35 @@ module Legion
350
443
  }
351
444
  end
352
445
 
353
- def publish_exception_event(event, level)
354
- lex_name = event[:lex] || 'core'
355
- comp = event[:component_type] || :unknown
356
- routing_key = "legion.logging.exception.#{level}.#{lex_name}.#{comp}"
357
- headers = build_exception_headers(event, comp, level)
358
- properties = build_exception_properties(event, level)
359
- Legion::Logging.exception_writer.call(event, routing_key: routing_key, headers: headers, properties: properties)
446
+ def forward_level?(level)
447
+ forwardable_log_levels.include?(level.to_sym)
448
+ end
449
+
450
+ # Levels whose logs build a structured event for the transport bus / Hooks.
451
+ # Runtime override: logging.transport.log_levels (Array of level names).
452
+ # Falls back to Settings.default[:forward_log_levels] (warn/error/fatal).
453
+ def forwardable_log_levels
454
+ default = Legion::Logging::Settings::DEFAULT_FORWARD_LOG_LEVELS
455
+ return default unless defined?(Legion::Settings)
456
+ return default unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
457
+
458
+ configured = Legion::Settings.dig(:logging, :transport, :log_levels)
459
+ return default unless configured.is_a?(Array) && !configured.empty?
460
+
461
+ configured.map { |lvl| lvl.to_s.to_sym }
462
+ rescue StandardError => e
463
+ Kernel.warn("Legion::Logging forwarding configuration failed: #{e.message}")
464
+ default
360
465
  end
361
466
 
362
467
  def build_writer_context(level, message)
468
+ # Cheap ivar/flag checks first: when nothing consumes forwarded events
469
+ # (the common case) we return before touching Settings, keeping the
470
+ # hot debug/info path allocation- and lookup-free.
363
471
  has_writer = !Legion::Logging.instance_variable_get(:@log_writer).nil?
364
472
  has_hooks = defined?(Legion::Logging::Hooks) && Legion::Logging::Hooks.enabled?
365
473
  return nil unless has_writer || has_hooks
474
+ return nil unless forward_level?(level)
366
475
 
367
476
  lex_val = instance_variable_defined?(:@lex) ? @lex : nil
368
477
  lex_segs = Thread.current[:legion_log_segments] || (instance_variable_defined?(:@lex_segments) ? @lex_segments : nil)
@@ -377,17 +486,7 @@ module Legion
377
486
  { level: level, event: event }
378
487
  end
379
488
 
380
- def fire_log_writer(level, message)
381
- lex_val = instance_variable_defined?(:@lex) ? @lex : nil
382
- lex_segs = Thread.current[:legion_log_segments] || (instance_variable_defined?(:@lex_segments) ? @lex_segments : nil)
383
-
384
- event = Legion::Logging::EventBuilder.build(
385
- level: level,
386
- message: message,
387
- lex: lex_val,
388
- lex_segments: lex_segs,
389
- caller_offset: 4
390
- )
489
+ def fire_log_writer(level, message, event:)
391
490
  lex_name = event[:lex] || 'core'
392
491
  component = event.dig(:caller, :file).to_s[COMPONENT_REGEX, 1] || 'unknown'
393
492
  routing_key = "legion.logging.log.#{level}.#{lex_name}.#{component}"
@@ -3,8 +3,10 @@
3
3
  module Legion
4
4
  module Logging
5
5
  class MultiIO
6
+ attr_reader :targets
7
+
6
8
  def initialize(*targets)
7
- @targets = targets.flatten
9
+ @targets = targets.flatten.freeze
8
10
  end
9
11
 
10
12
  def write(message)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Legion
6
+ module Logging
7
+ # Keeps the standard Logger interface used by Sinatra/APM on the async queue.
8
+ class OutputLogger < ::Logger
9
+ attr_accessor :async_writer
10
+
11
+ def add(severity, message = nil, progname = nil)
12
+ writer = @async_writer
13
+ return super unless writer && !writer.current_thread?
14
+
15
+ severity ||= UNKNOWN
16
+ return true if @logdev.nil? || severity < level
17
+
18
+ progname ||= @progname
19
+ if message.nil?
20
+ if block_given?
21
+ message = yield
22
+ else
23
+ message = progname
24
+ progname = @progname
25
+ end
26
+ end
27
+ enqueue_output(severity: severity, message: message, progname: progname)
28
+ end
29
+ alias log add
30
+
31
+ def <<(message)
32
+ writer = @async_writer
33
+ return super unless writer && !writer.current_thread?
34
+
35
+ enqueue_output(severity: nil, message: message, progname: nil)
36
+ end
37
+
38
+ private
39
+
40
+ def enqueue_output(severity:, message:, progname:) # rubocop:disable Naming/PredicateMethod
41
+ entry = Legion::Logging::AsyncWriter::OutputEntry.new(
42
+ severity: severity,
43
+ message: Legion::Logging::AsyncWriter.snapshot_value(value: message),
44
+ progname: Legion::Logging::AsyncWriter.snapshot_value(value: progname),
45
+ segments: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_segments]),
46
+ method_ctx: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_method]),
47
+ caller_trace: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_caller]),
48
+ conv_id: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_conv_id]),
49
+ request_id: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_request_id]),
50
+ exchange_id: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_exchange_id]),
51
+ chain_id: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_chain_id]),
52
+ origin: { timestamp: Time.now.utc, thread: Thread.current.object_id }
53
+ )
54
+ raise ClosedQueueError, 'async logging writer is unavailable' unless @async_writer.alive? && @async_writer.push(entry)
55
+
56
+ true
57
+ end
58
+ end
59
+ end
60
+ end
@@ -74,6 +74,9 @@ module Legion
74
74
  end
75
75
 
76
76
  def all_patterns
77
+ return PATTERNS unless defined?(Legion::Settings)
78
+ return PATTERNS unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
79
+
77
80
  @all_patterns ||= build_patterns
78
81
  end
79
82
 
@@ -85,8 +88,6 @@ module Legion
85
88
  end
86
89
 
87
90
  def custom_patterns
88
- return {} unless defined?(Legion::Settings)
89
-
90
91
  raw = Legion::Settings.dig(:logging, :redactor, :custom_patterns)
91
92
  return {} unless raw.is_a?(Hash)
92
93
 
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Logging
5
+ # Every cooperating writer for a path must use the same limits. The stable
6
+ # lock file coordinates separate devices/processes and must not be removed.
7
+ class RotatingFile
8
+ BUFFER_BYTES = 64 * 1024
9
+
10
+ attr_reader :file_path, :max_bytes, :max_files
11
+
12
+ def initialize(path:, max_bytes:, max_files:)
13
+ unless max_bytes.is_a?(Integer) && max_bytes.positive? && max_files.is_a?(Integer) && max_files.positive?
14
+ raise ArgumentError, 'max_bytes and max_files must be positive integers'
15
+ end
16
+
17
+ @file_path = File.expand_path(path)
18
+ @max_bytes = max_bytes
19
+ @max_files = max_files
20
+ @mutex = Mutex.new
21
+ @archive_pattern = /\A#{Regexp.escape(File.basename(@file_path))}\.([1-9]\d*)\z/
22
+ @needs_normalization = true
23
+ @closed = false
24
+ synchronize_path { normalize_locked }
25
+ rescue StandardError
26
+ begin
27
+ @io&.close
28
+ ensure
29
+ @lock_io&.close
30
+ end
31
+ raise
32
+ end
33
+
34
+ def write(*messages)
35
+ raise ArgumentError, 'wrong number of arguments (given 0, expected 1+)' if messages.empty?
36
+
37
+ synchronize_path do
38
+ normalize_locked if @needs_normalization
39
+ next @io.write(*messages) unless @regular
40
+
41
+ trim_file(path: @file_path) if @io.size > @max_bytes
42
+ messages.sum { |message| write_message(message: message.to_s) }
43
+ end
44
+ end
45
+
46
+ def flush
47
+ @mutex.synchronize do
48
+ raise IOError, 'closed stream' if @closed
49
+
50
+ @io&.flush
51
+ end
52
+ self
53
+ end
54
+
55
+ def sync = true # rubocop:disable Naming/PredicateMethod -- IO#sync interface
56
+
57
+ def sync=(_value); end
58
+
59
+ def close
60
+ @mutex.synchronize do
61
+ return if @closed
62
+
63
+ @closed = true
64
+ begin
65
+ @io&.close
66
+ ensure
67
+ @lock_io.close if @lock_io && !@lock_io.closed?
68
+ end
69
+ end
70
+ end
71
+
72
+ def closed?
73
+ @mutex.synchronize { @closed }
74
+ end
75
+
76
+ def size
77
+ synchronize_path { @io.size }
78
+ end
79
+
80
+ def rotate!
81
+ synchronize_path do
82
+ normalize_locked if @needs_normalization
83
+ rotate_locked!
84
+ end
85
+ end
86
+
87
+ def configure(max_bytes:, max_files:)
88
+ unless max_bytes.is_a?(Integer) && max_bytes.positive? && max_files.is_a?(Integer) && max_files.positive?
89
+ raise ArgumentError, 'max_bytes and max_files must be positive integers'
90
+ end
91
+
92
+ synchronize_path do
93
+ @max_bytes = max_bytes
94
+ @max_files = max_files
95
+ @needs_normalization = true
96
+ normalize_locked
97
+ end
98
+ self
99
+ end
100
+
101
+ private
102
+
103
+ def open_file
104
+ file = File.new(@file_path, 'ab')
105
+ file.sync = true
106
+ file
107
+ end
108
+
109
+ def synchronize_path
110
+ @mutex.synchronize do
111
+ raise IOError, 'closed stream' if @closed
112
+
113
+ lock_path if @lock_io || File.file?(@file_path) || !File.exist?(@file_path)
114
+ begin
115
+ reopen_if_replaced
116
+ if @regular && !@lock_io
117
+ @io.close
118
+ @io = nil
119
+ lock_path
120
+ reopen_if_replaced
121
+ end
122
+ yield
123
+ ensure
124
+ begin
125
+ # Idle devices must not pin archives after another writer unlinks
126
+ # them. Release every regular data-file FD before the path lock.
127
+ if @regular && @io
128
+ @io.close
129
+ @io = nil
130
+ end
131
+ ensure
132
+ @lock_io&.flock(File::LOCK_UN)
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ def lock_path
139
+ # flock locks are inherited across fork with the open file description.
140
+ # Reopen in the child so its lock competes with its parent's lock.
141
+ if @lock_io && @lock_pid != ::Process.pid
142
+ @lock_io.close
143
+ @lock_io = nil
144
+ end
145
+ @lock_io ||= File.open("#{@file_path}.lock", File::RDWR | File::CREAT, 0o600)
146
+ @lock_pid = ::Process.pid
147
+ raise IOError, 'could not acquire log rotation lock' unless @lock_io.flock(File::LOCK_EX)
148
+ end
149
+
150
+ def reopen_if_replaced
151
+ if @io
152
+ current = @io.stat
153
+ target = File.stat(@file_path) if File.exist?(@file_path)
154
+ return if target && current.dev == target.dev && current.ino == target.ino
155
+ end
156
+
157
+ replace_io
158
+ end
159
+
160
+ def replace_io
161
+ previous = @io
162
+ @io = open_file
163
+ current = @io.stat
164
+ @regular = current.file?
165
+ @needs_normalization ||= @file_dev != current.dev || @file_ino != current.ino
166
+ @file_dev = current.dev
167
+ @file_ino = current.ino
168
+ previous&.close
169
+ end
170
+
171
+ def normalize_locked
172
+ if @regular
173
+ trim_file(path: @file_path)
174
+ directory = File.dirname(@file_path)
175
+ Dir.each_child(directory) do |name|
176
+ match = @archive_pattern.match(name)
177
+ next unless match
178
+
179
+ archive = File.join(directory, name)
180
+ if match[1].to_i > @max_files
181
+ File.unlink(archive)
182
+ else
183
+ trim_file(path: archive)
184
+ end
185
+ end
186
+ end
187
+ @needs_normalization = false
188
+ end
189
+
190
+ def trim_file(path:)
191
+ stat = File.stat(path)
192
+ return unless stat.file? && stat.size > @max_bytes
193
+
194
+ # Move the retained tail toward the beginning without a temporary file
195
+ # or a full-file allocation. Forward copies cannot overwrite unread data.
196
+ File.open(path, 'r+b') do |file|
197
+ source_offset = file.size - @max_bytes
198
+ offset = 0
199
+ buffer = +''.b
200
+ while offset < @max_bytes
201
+ file.seek(source_offset + offset)
202
+ chunk = file.read([BUFFER_BYTES, @max_bytes - offset].min, buffer)
203
+ raise IOError, 'log file changed while trimming its retained tail' if chunk.nil? || chunk.empty?
204
+
205
+ file.seek(offset)
206
+ offset += file.write(chunk)
207
+ end
208
+ file.truncate(@max_bytes)
209
+ end
210
+ end
211
+
212
+ def write_message(message:)
213
+ offset = 0
214
+ available = @max_bytes - @io.size
215
+ while offset < message.bytesize
216
+ unless available.positive?
217
+ rotate_locked!
218
+ available = @max_bytes
219
+ end
220
+ length = [message.bytesize - offset, available, BUFFER_BYTES].min
221
+ chunk = offset.zero? && length == message.bytesize ? message : message.byteslice(offset, length)
222
+ written = @io.write(chunk)
223
+ offset += written
224
+ available -= written
225
+ end
226
+ offset
227
+ end
228
+
229
+ def rotate_locked! # rubocop:disable Naming/PredicateMethod -- Renames files; false means a nonregular destination.
230
+ return false unless @regular
231
+
232
+ Rotation.shift_files(path: @file_path, max_files: @max_files)
233
+ replace_io
234
+ @needs_normalization = false
235
+ true
236
+ end
237
+ end
238
+ end
239
+ end